feat(labs): biomarker catalog with server-resolved reference ranges

Add a user-scoped Biomarker catalog so a marker is defined once — name,
unit, lower/upper bound, optional encrypted context, panel — and every
later reading just links it. New /api/biomarkers CRUD plus a structured
/api/labs create path: a biomarkerId resolves the unit + reference range
server-side, so the list and detail DTOs carry the resolved values and
the client never recomputes the range. Lab reads include the linked
marker and fall back to the legacy free-text fields for unlinked rows.

Add a lab-result restore endpoint for the delete-Undo affordance, a
shared serialiser, a static common-panel seed list, and tests for
catalog validation, structured-entry resolution, and the context codec.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:05:51 +02:00
1 parent bee7295b19
commit e9037a0d92
16 files changed
+1194 -104

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
decryptContextFromBytes,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { annotate } from "@/lib/logging/context";
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
/**
* v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`).
*
* PUT applies a partial edit (`data` built field-by-field; an explicit `null`
* clears `context` / `panel` / a bound, an omitted key leaves it untouched).
* DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on
* `LabResult.biomarkerId` unlinks every reading without losing it (the row
* keeps its legacy `analyte` / `unit` historical fields). Cross-user rows
* surface as 404 (existence sealed).
*/
type RouteParams = { params: Promise<{ id: string }> };
function serialiseBiomarker(row: {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
contextEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
name: row.name,
unit: row.unit,
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
hasContext: row.contextEncrypted !== null,
context: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.biomarker.findFirst({ where: { id } });
if (!row || row.userId !== user.id) {
return apiError("Biomarker not found", 404);
}
annotate({
action: { name: "labs.biomarker.get" },
meta: { biomarkerId: id },
});
return apiSuccess(serialiseBiomarker(row));
},
);
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.biomarker.findFirst({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Biomarker not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.biomarker.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const d = parsed.data;
// A rename must not collide with another of the caller's markers.
if (d.name !== undefined && d.name !== existing.name) {
const clash = await prisma.biomarker.findFirst({
where: { userId: user.id, name: d.name },
select: { id: true },
});
if (clash) {
return apiError("A biomarker with this name already exists", 409);
}
}
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `context` / `panel` / a bound clears it.
const data: Record<string, unknown> = {};
if (d.name !== undefined) data.name = d.name;
if (d.unit !== undefined) data.unit = d.unit;
if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
if (d.upperBound !== undefined) data.upperBound = d.upperBound;
if (d.panel !== undefined) data.panel = d.panel;
if (d.context !== undefined) {
data.contextEncrypted = d.context
? encryptContextToBytes(d.context)
: null;
}
const updated = await prisma.biomarker.update({ where: { id }, data });
await auditLog("biomarker.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { biomarkerId: id },
});
annotate({
action: { name: "labs.biomarker.update" },
meta: { biomarkerId: id },
});
return apiSuccess(serialiseBiomarker(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.biomarker.findFirst({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Biomarker not found", 404);
}
// Hard delete — the `onDelete: SetNull` FK unlinks every reading rather
// than cascading it away. The readings keep their legacy `analyte` /
// `unit` so the history survives the catalog edit.
await prisma.biomarker.delete({ where: { id } });
await auditLog("biomarker.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { biomarkerId: id },
});
annotate({
action: { name: "labs.biomarker.delete" },
meta: { biomarkerId: id },
});
return apiSuccess({ deleted: true });
},
);
+151
View File
@@ -0,0 +1,151 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import {
decryptContextFromBytes,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { annotate } from "@/lib/logging/context";
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
/**
* v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`).
*
* The catalog is the Labs feature's primary object: a marker is defined ONCE
* (name, unit, reference bounds, optional context) and every later reading
* just picks it. GET lists the caller's markers; POST defines a new one.
* `userId` is always narrowed from the session — never a body field — and the
* write `data` object is built field-by-field (no mass assignment). The
* optional context note is AES-256-GCM encrypted into the `contextEncrypted`
* Bytes column before write. The `@@unique([userId, name])` index means there
* is never a second "LDL" definition for one user.
*/
// The encrypted context bytes are never echoed back; `hasContext` flags
// presence and the single-resource GET returns the decrypted text.
function serialiseBiomarker(row: {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
contextEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
name: row.name,
unit: row.unit,
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
hasContext: row.contextEncrypted !== null,
context: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rows = await prisma.biomarker.findMany({
where: { userId: user.id },
orderBy: [{ name: "asc" }],
});
annotate({
action: { name: "labs.biomarker.list" },
meta: { total: rows.length },
});
return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
async function postBiomarker(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.biomarker.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
// Reject a duplicate name up front so the client sees a clean 409 rather
// than a Prisma unique-constraint 500. The `@@unique([userId, name])` index
// is the structural backstop.
const existing = await prisma.biomarker.findFirst({
where: { userId: user.id, name },
select: { id: true },
});
if (existing) {
return apiError("A biomarker with this name already exists", 409);
}
// Field-by-field assignment — never spread `parsed.data`.
const created = await prisma.biomarker.create({
data: {
userId: user.id,
name,
unit,
lowerBound: lowerBound ?? null,
upperBound: upperBound ?? null,
panel: panel ?? null,
contextEncrypted: context ? encryptContextToBytes(context) : null,
},
});
await auditLog("biomarker.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { biomarkerId: created.id },
});
annotate({
action: { name: "labs.biomarker.create" },
meta: { biomarkerId: created.id },
});
return apiSuccess(serialiseBiomarker(created), 201);
}
+50 -45
View File
@@ -11,12 +11,14 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
type ResolvedBiomarker,
serialiseLabResult,
serialiseLabResultDetail,
} from "@/lib/labs/serialise";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
classifyReferenceRange,
updateLabResultSchema,
} from "@/lib/validations/labs";
import { updateLabResultSchema } from "@/lib/validations/labs";
/**
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
@@ -26,10 +28,36 @@ import {
* (`data` built field-by-field; an explicit `null` clears `panel` / `note` /
* a reference bound, an omitted key leaves it untouched). DELETE soft-deletes
* by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed).
*
* v1.18.1 — when the row links a `Biomarker`, the response resolves the
* canonical name / unit / range from the catalog (server-authoritative). The
* legacy per-row free-text fields stay editable for an unlinked row.
*/
type RouteParams = { params: Promise<{ id: string }> };
const biomarkerSelect = {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
} as const;
function toResolved(
bm: {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
} | null,
): ResolvedBiomarker | null {
return bm ?? null;
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -37,6 +65,7 @@ export const GET = apiHandler(
const row = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
include: { biomarker: { select: biomarkerSelect } },
});
if (!row || row.userId !== user.id) {
return apiError("Lab result not found", 404);
@@ -44,27 +73,13 @@ export const GET = apiHandler(
annotate({ action: { name: "labs.get" }, meta: { labResultId: id } });
return apiSuccess({
id: row.id,
panel: row.panel,
analyte: row.analyte,
value: row.value,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
note: row.noteEncrypted
? decryptNoteFromBytes(row.noteEncrypted)
: null,
rangeStatus: classifyReferenceRange(
row.value,
row.referenceLow,
row.referenceHigh,
return apiSuccess(
serialiseLabResultDetail(
row,
toResolved(row.biomarker),
row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
);
},
);
@@ -112,6 +127,8 @@ export const PUT = apiHandler(
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
// A reading linked to a catalog marker is edited via VALUE / takenAt /
// note here; its name / unit / range stay resolved from the biomarker.
const data: Record<string, unknown> = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
@@ -124,7 +141,11 @@ export const PUT = apiHandler(
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
const updated = await prisma.labResult.update({ where: { id }, data });
const updated = await prisma.labResult.update({
where: { id },
data,
include: { biomarker: { select: biomarkerSelect } },
});
await auditLog("labResult.update", {
userId: user.id,
@@ -134,25 +155,9 @@ export const PUT = apiHandler(
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
return apiSuccess({
id: updated.id,
panel: updated.panel,
analyte: updated.analyte,
value: updated.value,
unit: updated.unit,
referenceLow: updated.referenceLow,
referenceHigh: updated.referenceHigh,
takenAt: updated.takenAt.toISOString(),
source: updated.source,
hasNote: updated.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
updated.value,
updated.referenceLow,
updated.referenceHigh,
),
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});
return apiSuccess(
serialiseLabResult(updated, toResolved(updated.biomarker)),
);
},
);
@@ -178,7 +183,7 @@ export const DELETE = apiHandler(
await auditLog("labResult.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: id, analyte: existing.analyte },
details: { labResultId: id },
});
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
+73
View File
@@ -0,0 +1,73 @@
/**
* `POST /api/labs/restore` — un-tombstone for the lab-history delete-Undo
* affordance (v1.18.1).
*
* Body: `{ ids: string[] }` — 1..200 lab-result ids, scoped to the caller.
*
* Clears `deletedAt` on every owned, currently-tombstoned row in one
* `updateMany`, so the reading re-surfaces in normal reads. A forged /
* foreign / not-deleted id is a silent no-op (never a 404 existence leak):
* the mutation `where` pins `userId` so it only ever touches the caller's
* rows. Mirrors `/api/measurements/restore`.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
const MAX_IDS_PER_BATCH = 200;
const RATE_LIMIT_MAX = 60;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const restoreSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`labs:restore:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many restore requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.restore.invalid",
});
}
const { ids } = parsed.data;
// `userId`-scoped + `deletedAt != null`: a foreign / live id is skipped.
const { count } = await prisma.labResult.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null },
});
annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
return apiSuccess({ restored: count });
}
+93 -56
View File
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -11,10 +12,13 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import {
type ResolvedBiomarker,
serialiseLabResult,
} from "@/lib/labs/serialise";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
} from "@/lib/validations/labs";
@@ -22,49 +26,31 @@ import {
/**
* v1.17.1 — structured lab-result store (`/api/labs`).
*
* GET lists the caller's live results with optional analyte / panel / date
* filters. POST records a single reading. `userId` is always narrowed from
* the session — never a body field — and the write `data` object is built
* field-by-field (no mass assignment). The free-text note, when present, is
* AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write.
* GET lists the caller's live results with optional biomarker / analyte /
* panel / date filters. POST records a single reading. `userId` is always
* narrowed from the session — never a body field — and the write `data`
* object is built field-by-field (no mass assignment). The free-text note,
* when present, is AES-256-GCM encrypted into the `noteEncrypted` Bytes
* column before write.
*
* v1.18.1 — structured entry: when the body carries a `biomarkerId`, the row
* links the user-scoped catalog marker and the response resolves its unit +
* reference range FROM the biomarker (server-authoritative). The web + iOS
* clients render the resolved DTO and never recompute.
*/
// The serialised row never echoes the encrypted note bytes back; the badge
// status is computed server-side so the client renders a coherent, neutral
// in/out-of-range verdict without re-deriving the rule.
function serialiseLabResult(row: {
id: string;
panel: string | null;
analyte: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: Date;
source: string;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
panel: row.panel,
analyte: row.analyte,
value: row.value,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
row.value,
row.referenceLow,
row.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
/** Map the joined biomarker (or null) into the resolver's shape. */
function toResolved(
bm: {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
} | null,
): ResolvedBiomarker | null {
return bm ?? null;
}
export const GET = apiHandler(async (request: NextRequest) => {
@@ -80,11 +66,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } =
parsed.data;
const where = {
userId: user.id,
deletedAt: null,
...(biomarkerId && { biomarkerId }),
...(analyte && { analyte }),
...(panel && { panel }),
...(from || to
@@ -103,6 +91,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { takenAt: sortDir },
take: limit,
skip: offset,
include: {
biomarker: {
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
},
},
},
}),
prisma.labResult.count({ where }),
]);
@@ -113,7 +113,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
return apiSuccess({
results: rows.map(serialiseLabResult),
results: rows.map((row) =>
serialiseLabResult(row, toResolved(row.biomarker)),
),
meta: { total, limit, offset },
});
});
@@ -153,20 +155,55 @@ async function postLabResult(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } =
parsed.data;
const {
biomarkerId,
panel,
analyte,
value,
unit,
referenceLow,
referenceHigh,
takenAt,
note,
} = parsed.data;
// Field-by-field assignment — never spread `parsed.data`. `source` is
// hardcoded "MANUAL" for this user-facing path; import paths set their own.
// Structured-entry path: resolve the catalog marker (and verify ownership)
// so the row's name + unit derive from it. A forged / foreign id is a 404.
let biomarker: ResolvedBiomarker | null = null;
if (biomarkerId) {
const found = await prisma.biomarker.findFirst({
where: { id: biomarkerId, userId: user.id },
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
},
});
if (!found) {
return apiError("Biomarker not found", 404);
}
biomarker = found;
}
// Field-by-field assignment — never spread `parsed.data`. With a catalog
// link the row stamps the resolved name/unit/range as historical truth
// (so a later catalog edit does not silently rewrite a past reading) AND
// keeps the FK; reads resolve the CURRENT catalog values via `serialise`.
const created = await prisma.labResult.create({
data: {
userId: user.id,
panel: panel ?? null,
analyte,
biomarkerId: biomarker?.id ?? null,
panel: biomarker ? biomarker.panel : (panel ?? null),
analyte: biomarker ? biomarker.name : (analyte as string),
value,
unit,
referenceLow: referenceLow ?? null,
referenceHigh: referenceHigh ?? null,
unit: biomarker ? biomarker.unit : (unit as string),
referenceLow: biomarker ? biomarker.lowerBound : (referenceLow ?? null),
referenceHigh: biomarker
? biomarker.upperBound
: (referenceHigh ?? null),
takenAt,
source: "MANUAL",
noteEncrypted: note ? encryptNoteToBytes(note) : null,
@@ -176,13 +213,13 @@ async function postLabResult(request: NextRequest) {
await auditLog("labResult.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: created.id, analyte: created.analyte },
details: { labResultId: created.id },
});
annotate({
action: { name: "labs.create" },
meta: { labResultId: created.id },
meta: { labResultId: created.id, structured: biomarker !== null },
});
return apiSuccess(serialiseLabResult(created), 201);
return apiSuccess(serialiseLabResult(created, biomarker), 201);
}
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
decryptContextFromBytes,
encryptContextToBytes,
} from "../biomarker-store";
const KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
describe("biomarker context codec", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY);
_resetCryptoCacheForTests();
});
it("round-trips a UTF-8 context note through Bytes", () => {
const plain = "Nüchtern · morgens · Lipidpanel";
const bytes = encryptContextToBytes(plain);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(decryptContextFromBytes(bytes)).toBe(plain);
});
it("does not store the plaintext verbatim in the bytes", () => {
const plain = "secret context";
const bytes = encryptContextToBytes(plain);
const asString = Buffer.from(bytes).toString("utf8");
expect(asString).not.toContain(plain);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
resolveLabFields,
serialiseLabResult,
serialiseLabResultDetail,
type LabRow,
type ResolvedBiomarker,
} from "../serialise";
const baseRow: LabRow = {
id: "lr_1",
panel: "Legacy panel",
analyte: "ldl", // lowercase legacy spelling
value: 130,
unit: "mg/dl", // lowercase legacy unit
referenceLow: null,
referenceHigh: 100, // stale per-row bound
takenAt: new Date("2026-01-01T08:00:00.000Z"),
source: "MANUAL",
biomarkerId: "bm_1",
noteEncrypted: null,
createdAt: new Date("2026-01-01T08:00:00.000Z"),
updatedAt: new Date("2026-01-01T08:00:00.000Z"),
};
const biomarker: ResolvedBiomarker = {
id: "bm_1",
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: null,
upperBound: 116,
panel: "Lipids",
};
describe("resolveLabFields", () => {
it("prefers the linked biomarker over the legacy row fields", () => {
const r = resolveLabFields(baseRow, biomarker);
expect(r.analyte).toBe("LDL Cholesterol");
expect(r.unit).toBe("mg/dL");
expect(r.referenceHigh).toBe(116);
expect(r.panel).toBe("Lipids");
});
it("falls back to the row fields when unlinked", () => {
const r = resolveLabFields({ ...baseRow, biomarkerId: null }, null);
expect(r.analyte).toBe("ldl");
expect(r.unit).toBe("mg/dl");
expect(r.referenceHigh).toBe(100);
expect(r.panel).toBe("Legacy panel");
});
});
describe("serialiseLabResult", () => {
it("computes the verdict from the RESOLVED catalog bounds, not the stale row", () => {
// value 130 vs row's stale high 100 → would be 'above';
// vs catalog high 116 → still 'above', but the resolved bound is what counts.
const dto = serialiseLabResult(baseRow, biomarker);
expect(dto.unit).toBe("mg/dL");
expect(dto.referenceHigh).toBe(116);
expect(dto.rangeStatus).toBe("above");
expect(dto.biomarkerId).toBe("bm_1");
expect(dto.hasNote).toBe(false);
});
it("in-range when the resolved catalog band contains the value", () => {
const within = serialiseLabResult({ ...baseRow, value: 90 }, biomarker);
expect(within.rangeStatus).toBe("in-range");
});
it("uses legacy fields and verdict when unlinked", () => {
const dto = serialiseLabResult({ ...baseRow, biomarkerId: null }, null);
expect(dto.unit).toBe("mg/dl");
// 130 > row high 100 → above
expect(dto.rangeStatus).toBe("above");
expect(dto.biomarkerId).toBeNull();
});
it("flags an encrypted note without echoing it", () => {
const dto = serialiseLabResult(
{ ...baseRow, noteEncrypted: new Uint8Array([1, 2, 3]) },
biomarker,
);
expect(dto.hasNote).toBe(true);
expect(dto).not.toHaveProperty("noteEncrypted");
});
});
describe("serialiseLabResultDetail", () => {
it("carries the decrypted note in place of hasNote", () => {
const dto = serialiseLabResultDetail(baseRow, biomarker, "fasting sample");
expect(dto.note).toBe("fasting sample");
expect(dto).not.toHaveProperty("hasNote");
expect(dto.unit).toBe("mg/dL");
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* v1.18.1 — static seed catalog of common blood/lab biomarkers.
*
* This is NOT a DB table. It is a curated list offered at the add step
* ("pick a common marker, or create your own") so a self-hoster does not
* type a marker definition from scratch. Selecting a seed pre-fills the
* inline definition form (canonical name, unit, suggested reference bounds)
* which the user then accepts or overrides before it becomes a real,
* user-scoped `Biomarker` row.
*
* The suggested ranges are EDITABLE DEFAULTS, never gospel — reference
* windows vary by lab, sex, and age. The display name is localised via the
* `labs.catalog.<slug>` i18n key; the EN slug is the stable identity (mirrors
* the Insights slug/alias pattern). Units stay literal (mg/dL etc. are not
* translated).
*
* Source ranges: common adult fasting reference windows (US conventional
* units where the metric/SI split matters). Presented as starting points.
*/
export interface BiomarkerSeed {
/** Stable EN identity; the `labs.catalog.<slug>` i18n key resolves the name. */
slug: string;
/** Panel grouping key; the `labs.catalog.panel.<panel>` i18n key labels it. */
panel: string;
/** Canonical unit for every reading against this marker. */
unit: string;
/** Suggested lower bound (editable default), or null when open-ended. */
lowerBound: number | null;
/** Suggested upper bound (editable default), or null when open-ended. */
upperBound: number | null;
}
export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
// Lipids
{ slug: "total-cholesterol", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 200 },
{ slug: "ldl", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 116 },
{ slug: "hdl", panel: "lipids", unit: "mg/dL", lowerBound: 40, upperBound: null },
{ slug: "triglycerides", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 150 },
// Glucose metabolism
{ slug: "fasting-glucose", panel: "glucose", unit: "mg/dL", lowerBound: 70, upperBound: 100 },
{ slug: "hba1c", panel: "glucose", unit: "%", lowerBound: null, upperBound: 5.7 },
// Thyroid
{ slug: "tsh", panel: "thyroid", unit: "mIU/L", lowerBound: 0.4, upperBound: 4 },
{ slug: "ft3", panel: "thyroid", unit: "pg/mL", lowerBound: 2.3, upperBound: 4.2 },
{ slug: "ft4", panel: "thyroid", unit: "ng/dL", lowerBound: 0.8, upperBound: 1.8 },
// Iron
{ slug: "ferritin", panel: "iron", unit: "ng/mL", lowerBound: 30, upperBound: 400 },
{ slug: "transferrin-saturation", panel: "iron", unit: "%", lowerBound: 20, upperBound: 50 },
// Vitamins
{ slug: "vitamin-d", panel: "vitamins", unit: "ng/mL", lowerBound: 30, upperBound: 100 },
{ slug: "vitamin-b12", panel: "vitamins", unit: "pg/mL", lowerBound: 200, upperBound: 900 },
{ slug: "folate", panel: "vitamins", unit: "ng/mL", lowerBound: 3, upperBound: 20 },
// Inflammation
{ slug: "hs-crp", panel: "inflammation", unit: "mg/L", lowerBound: null, upperBound: 3 },
// Renal
{ slug: "creatinine", panel: "renal", unit: "mg/dL", lowerBound: 0.6, upperBound: 1.3 },
{ slug: "egfr", panel: "renal", unit: "mL/min/1.73m²", lowerBound: 90, upperBound: null },
// Liver
{ slug: "alt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 },
{ slug: "ast", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 },
{ slug: "ggt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 55 },
// Electrolytes
{ slug: "sodium", panel: "electrolytes", unit: "mmol/L", lowerBound: 135, upperBound: 145 },
{ slug: "potassium", panel: "electrolytes", unit: "mmol/L", lowerBound: 3.5, upperBound: 5.1 },
// Blood count
{ slug: "hemoglobin", panel: "bloodCount", unit: "g/dL", lowerBound: 12, upperBound: 17.5 },
{ slug: "hematocrit", panel: "bloodCount", unit: "%", lowerBound: 36, upperBound: 50 },
{ slug: "wbc", panel: "bloodCount", unit: "10³/µL", lowerBound: 4, upperBound: 11 },
{ slug: "platelets", panel: "bloodCount", unit: "10³/µL", lowerBound: 150, upperBound: 400 },
] as const;
/** The set of panel keys present in the catalog, in display order. */
export const BIOMARKER_PANELS = [
"lipids",
"glucose",
"thyroid",
"iron",
"vitamins",
"inflammation",
"renal",
"liver",
"electrolytes",
"bloodCount",
] as const;
export type BiomarkerPanelKey = (typeof BIOMARKER_PANELS)[number];
+30
View File
@@ -0,0 +1,30 @@
/**
* v1.18.1 — server-side helpers for the user-scoped Biomarker catalog.
*
* The AES-256-GCM ↔ `Bytes` codec for `Biomarker.contextEncrypted`. The
* per-marker context note ("what this means") is the only sensitive column on
* the model; it shares the `encrypt()` string format (`"<keyId>.<base64>"`)
* every other `*Encrypted` column uses, encoded as UTF-8 bytes.
*
* Mirrors the `LabResult.noteEncrypted` codec in `./store.ts` so the two
* encrypted columns on the Labs feature share one byte layout.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
export function encryptContextToBytes(
plaintext: string,
): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a stored `Bytes` context note back to plaintext. */
export function decryptContextFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
+13
View File
@@ -0,0 +1,13 @@
/**
* v1.18.1 — shared lab-value number formatting.
*
* The list card, the detail history rows, and the chart tooltip all trim a
* lab reading the same way: whole numbers render bare, fractional values keep
* up to two decimals (no trailing-zero noise). Centralised so the three
* surfaces stay byte-identical.
*/
export function formatLabValue(value: number): string {
return Number.isInteger(value)
? String(value)
: String(Number(value.toFixed(2)));
}
+150
View File
@@ -0,0 +1,150 @@
/**
* v1.18.1 — server-authoritative lab-result DTO resolution.
*
* A lab row resolves its display name + unit + reference bounds in ONE place:
*
* - If the row links a `Biomarker` (`biomarkerId` set), the canonical name,
* unit, and reference bounds come FROM THE BIOMARKER. The per-row legacy
* `analyte` / `unit` / `reference*` columns are ignored — they are stale
* historical truth at that point.
* - If the row is unlinked (legacy / pre-backfill), it falls back to its own
* free-text `analyte` / `unit` / `reference*` columns.
*
* Either way the verdict (`rangeStatus`) is computed from the RESOLVED bounds
* via `classifyReferenceRange`. The DTO carries the resolved values so the web
* client AND the iOS client render the same numbers — neither recomputes the
* range or guesses the unit. This is the server-authoritative-parity rule for
* Labs.
*/
import { classifyReferenceRange } from "@/lib/labs/reference-range";
/** The minimal biomarker shape the resolver needs (no encrypted context). */
export interface ResolvedBiomarker {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
}
/** The lab-result row shape the resolver reads (Prisma row subset). */
export interface LabRow {
id: string;
panel: string | null;
analyte: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: Date;
source: string;
biomarkerId: string | null;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Resolve the canonical name / unit / bounds / panel for a lab row, preferring
* the linked biomarker over the legacy per-row fields. Returns a plain shape
* the list + detail serialisers both build on.
*/
export function resolveLabFields(
row: Pick<
LabRow,
| "analyte"
| "unit"
| "referenceLow"
| "referenceHigh"
| "panel"
| "biomarkerId"
>,
biomarker: ResolvedBiomarker | null | undefined,
): {
analyte: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
} {
if (biomarker) {
return {
analyte: biomarker.name,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
panel: biomarker.panel,
};
}
return {
analyte: row.analyte,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
panel: row.panel,
};
}
/**
* Serialise a lab row to the list DTO — never echoes the encrypted note bytes
* (only the `hasNote` flag) and resolves name/unit/range server-side.
*/
export function serialiseLabResult(
row: LabRow,
biomarker?: ResolvedBiomarker | null,
) {
const resolved = resolveLabFields(row, biomarker);
return {
id: row.id,
biomarkerId: row.biomarkerId,
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Serialise a lab row to the detail DTO — same as the list DTO but carries the
* decrypted `note` instead of the `hasNote` flag.
*/
export function serialiseLabResultDetail(
row: LabRow,
biomarker: ResolvedBiomarker | null | undefined,
note: string | null,
) {
const resolved = resolveLabFields(row, biomarker);
return {
id: row.id,
biomarkerId: row.biomarkerId,
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
note,
rangeStatus: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
+7
View File
@@ -9,6 +9,7 @@ export const labKeys = {
labResults: () => ["lab-results"] as const,
labResultsList: (params: {
biomarkerId?: string | undefined;
analyte: string | undefined;
panel: string | undefined;
from: string | undefined;
@@ -19,6 +20,7 @@ export const labKeys = {
[
"lab-results",
"list",
params.biomarkerId ?? null,
params.analyte ?? null,
params.panel ?? null,
params.from ?? null,
@@ -26,4 +28,9 @@ export const labKeys = {
params.page,
params.sortDir,
] as const,
// v1.18.1 — user-scoped Biomarker catalog. The list feeds the picker and
// the manager; both invalidate `biomarkers()` after a mutation.
biomarkers: () => ["biomarkers"] as const,
biomarkerDetail: (id: string) => ["biomarkers", "detail", id] as const,
};
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "../biomarkers";
describe("createBiomarkerSchema", () => {
it("accepts a minimal marker (name + unit only)", () => {
const r = createBiomarkerSchema.safeParse({ name: "LDL", unit: "mg/dL" });
expect(r.success).toBe(true);
});
it("accepts both bounds when low <= high", () => {
const r = createBiomarkerSchema.safeParse({
name: "Fasting glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
});
expect(r.success).toBe(true);
});
it("accepts an open-ended upper bound (lower omitted)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
upperBound: 116,
});
expect(r.success).toBe(true);
});
it("rejects a transposed range (low > high)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
lowerBound: 200,
upperBound: 100,
});
expect(r.success).toBe(false);
});
it("rejects an empty name", () => {
const r = createBiomarkerSchema.safeParse({ name: " ", unit: "mg/dL" });
expect(r.success).toBe(false);
});
it("normalises an empty context to undefined", () => {
const r = createBiomarkerSchema.safeParse({
name: "TSH",
unit: "mIU/L",
context: "",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.context).toBeUndefined();
});
});
describe("updateBiomarkerSchema", () => {
it("accepts a partial edit", () => {
const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" });
expect(r.success).toBe(true);
});
it("accepts an explicit null to clear a bound", () => {
const r = updateBiomarkerSchema.safeParse({ lowerBound: null });
expect(r.success).toBe(true);
if (r.success) expect(r.data.lowerBound).toBeNull();
});
it("still rejects a transposed range on update", () => {
const r = updateBiomarkerSchema.safeParse({
lowerBound: 10,
upperBound: 5,
});
expect(r.success).toBe(false);
});
});
@@ -75,6 +75,32 @@ describe("createLabResultSchema", () => {
expect(r.success).toBe(true);
});
it("accepts a structured entry (biomarkerId, no analyte/unit)", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects an entry with neither biomarkerId nor analyte", () => {
const r = createLabResultSchema.safeParse({
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a free-text entry missing the unit", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("normalises an empty-string panel to undefined", () => {
const r = createLabResultSchema.safeParse({
analyte: "TSH",
+89
View File
@@ -0,0 +1,89 @@
/**
* v1.18.1 — user-scoped Biomarker catalog validation.
*
* A `Biomarker` defines a lab marker ONCE: canonical name, unit, reference
* bounds, and an optional context note. Recording a value later is just
* picking the biomarker — the unit and reference range are resolved
* server-side from the catalog row, never re-entered (ending the per-row
* free-text divergence: "LDL" vs "ldl", `mg/dL` vs `mg/dl`).
*
* The reference window is the lab/sex/age-dependent "normal" range; the
* server's neutral range verdict (`classifyReferenceRange`) is computed
* against these bounds. Both are independently optional (a marker may report
* only an upper bound, e.g. LDL < 116); when both are present the lower must
* not exceed the upper.
*/
import { z } from "zod/v4";
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
const upperBound = z.number().finite().optional();
const optionalContext = z
.string()
.trim()
.max(2000)
.optional()
// An empty / whitespace-only context normalises to "no context" so a
// blank field clears rather than stores an empty string.
.transform((v) => (v && v.length > 0 ? v : undefined));
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
.or(z.literal("").transform(() => undefined));
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
lowerBound,
upperBound,
context: optionalContext,
panel: optionalPanel,
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.upperBound === undefined ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
/**
* Update schema: every field optional (partial edit). `context` / `panel` /
* a bound accept an explicit `null` to clear the stored value — distinct from
* an omitted key, which leaves the column untouched.
*/
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
panel: optionalPanel.or(z.null()),
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.lowerBound === null ||
d.upperBound === undefined ||
d.upperBound === null ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type UpdateBiomarkerInput = z.infer<typeof updateBiomarkerSchema>;
+26 -3
View File
@@ -68,17 +68,38 @@ const optionalNote = z
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
* from the biomarker server-side, so `analyte` / `unit` may be omitted (the
* route fills them from the catalog row). A free-text reading without a
* catalog link stays valid (quick-capture / legacy path).
*/
const biomarkerId = z.string().trim().min(1).max(64).optional();
export const createLabResultSchema = z
.object({
biomarkerId,
panel: optionalPanel,
analyte: requiredText(120),
analyte: requiredText(120).optional(),
value: z.number().finite(),
unit: requiredText(40),
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
takenAt: takenAtField,
note: optionalNote,
})
// Either a catalog link OR a free-text analyte must be present.
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {
message: "Either biomarkerId or analyte is required",
path: ["analyte"],
})
// Without a catalog link the unit is required (the catalog otherwise
// supplies it server-side).
.refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, {
message: "unit is required when no biomarkerId is given",
path: ["unit"],
})
.refine(
(d) =>
d.referenceLow === undefined ||
@@ -123,8 +144,10 @@ export const updateLabResultSchema = z
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
/** List query: filter by analyte (exact) + date range, paginate. */
/** List query: filter by biomarker / analyte (exact) + date range, paginate. */
export const listLabResultsSchema = z.object({
// v1.18.1 — filter to one catalog marker's readings (the detail chart feed).
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
from: z