From e9037a0d9298933e32d2e92a93d22225375d27cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:05:51 +0200 Subject: [PATCH 1/4] feat(labs): biomarker catalog with server-resolved reference ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/biomarkers/[id]/route.ts | 190 ++++++++++++++++++ src/app/api/biomarkers/route.ts | 151 ++++++++++++++ src/app/api/labs/[id]/route.ts | 95 ++++----- src/app/api/labs/restore/route.ts | 73 +++++++ src/app/api/labs/route.ts | 149 ++++++++------ .../labs/__tests__/biomarker-store.test.ts | 35 ++++ src/lib/labs/__tests__/serialise.test.ts | 96 +++++++++ src/lib/labs/biomarker-catalog.ts | 87 ++++++++ src/lib/labs/biomarker-store.ts | 30 +++ src/lib/labs/format-value.ts | 13 ++ src/lib/labs/serialise.ts | 150 ++++++++++++++ src/lib/query-keys/labs.ts | 7 + .../validations/__tests__/biomarkers.test.ts | 78 +++++++ src/lib/validations/__tests__/labs.test.ts | 26 +++ src/lib/validations/biomarkers.ts | 89 ++++++++ src/lib/validations/labs.ts | 29 ++- 16 files changed, 1194 insertions(+), 104 deletions(-) create mode 100644 src/app/api/biomarkers/[id]/route.ts create mode 100644 src/app/api/biomarkers/route.ts create mode 100644 src/app/api/labs/restore/route.ts create mode 100644 src/lib/labs/__tests__/biomarker-store.test.ts create mode 100644 src/lib/labs/__tests__/serialise.test.ts create mode 100644 src/lib/labs/biomarker-catalog.ts create mode 100644 src/lib/labs/biomarker-store.ts create mode 100644 src/lib/labs/format-value.ts create mode 100644 src/lib/labs/serialise.ts create mode 100644 src/lib/validations/__tests__/biomarkers.test.ts create mode 100644 src/lib/validations/biomarkers.ts diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts new file mode 100644 index 000000000..48be5a921 --- /dev/null +++ b/src/app/api/biomarkers/[id]/route.ts @@ -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 = {}; + 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 }); + }, +); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts new file mode 100644 index 000000000..61a5b2f14 --- /dev/null +++ b/src/app/api/biomarkers/route.ts @@ -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); +} diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 829f88498..9463efa45 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -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 = {}; 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 } }); diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts new file mode 100644 index 000000000..07d0bc409 --- /dev/null +++ b/src/app/api/labs/restore/route.ts @@ -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 { + 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 }); +} diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index a80ff3f41..cc9395b14 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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); } diff --git a/src/lib/labs/__tests__/biomarker-store.test.ts b/src/lib/labs/__tests__/biomarker-store.test.ts new file mode 100644 index 000000000..fd8b849af --- /dev/null +++ b/src/lib/labs/__tests__/biomarker-store.test.ts @@ -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); + }); +}); diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts new file mode 100644 index 000000000..d8aa335ad --- /dev/null +++ b/src/lib/labs/__tests__/serialise.test.ts @@ -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"); + }); +}); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts new file mode 100644 index 000000000..05647fbd6 --- /dev/null +++ b/src/lib/labs/biomarker-catalog.ts @@ -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.` 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.` i18n key resolves the name. */ + slug: string; + /** Panel grouping key; the `labs.catalog.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]; diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts new file mode 100644 index 000000000..17a2b2234 --- /dev/null +++ b/src/lib/labs/biomarker-store.ts @@ -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 (`"."`) + * 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 { + 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")); +} diff --git a/src/lib/labs/format-value.ts b/src/lib/labs/format-value.ts new file mode 100644 index 000000000..768a031cb --- /dev/null +++ b/src/lib/labs/format-value.ts @@ -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))); +} diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts new file mode 100644 index 000000000..77a8cf29c --- /dev/null +++ b/src/lib/labs/serialise.ts @@ -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(), + }; +} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 8f47ac820..82095873e 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -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, }; diff --git a/src/lib/validations/__tests__/biomarkers.test.ts b/src/lib/validations/__tests__/biomarkers.test.ts new file mode 100644 index 000000000..c45c140d9 --- /dev/null +++ b/src/lib/validations/__tests__/biomarkers.test.ts @@ -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); + }); +}); diff --git a/src/lib/validations/__tests__/labs.test.ts b/src/lib/validations/__tests__/labs.test.ts index 712ce8328..3f090b01e 100644 --- a/src/lib/validations/__tests__/labs.test.ts +++ b/src/lib/validations/__tests__/labs.test.ts @@ -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", diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts new file mode 100644 index 000000000..23a9e4ebb --- /dev/null +++ b/src/lib/validations/biomarkers.ts @@ -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; + +/** + * 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; diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 3a28ee7b2..1bad16de2 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -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; -/** 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 From e4722fb9a112356917ccae12d4487a3d2903eda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:05:58 +0200 Subject: [PATCH 2/4] feat(labs): backfill job linking legacy readings to the catalog Add a one-shot pg-boss job that heals the per-row free-text divergence: for each user it groups un-linked live readings by lower(analyte), mints (or reuses) one Biomarker per group from the most-recent report's spelling/unit/range, and stamps biomarkerId on every row. Registered in allQueues with a boot-time discovery enqueue; idempotent across reboots (a completed pass drops the user from the discovery set). --- .../__tests__/lab-biomarker-backfill.test.ts | 63 +++++ src/lib/jobs/lab-biomarker-backfill.ts | 217 ++++++++++++++++++ src/lib/jobs/reminder-worker.ts | 58 +++++ 3 files changed, 338 insertions(+) create mode 100644 src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts create mode 100644 src/lib/jobs/lab-biomarker-backfill.ts diff --git a/src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts b/src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts new file mode 100644 index 000000000..463b9a0aa --- /dev/null +++ b/src/lib/jobs/__tests__/lab-biomarker-backfill.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { groupRowsByAnalyte } from "../lab-biomarker-backfill"; + +function row( + id: string, + analyte: string, + unit: string, + takenAt: string, + opts: { low?: number | null; high?: number | null; panel?: string | null } = {}, +) { + return { + id, + analyte, + unit, + referenceLow: opts.low ?? null, + referenceHigh: opts.high ?? null, + panel: opts.panel ?? null, + takenAt: new Date(takenAt), + }; +} + +describe("groupRowsByAnalyte", () => { + it("collapses case/spacing variants of the same analyte into one group", () => { + const groups = groupRowsByAnalyte([ + row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"), + row("b", "ldl", "mg/dl", "2026-02-01T00:00:00Z"), + row("c", " LDL ", "mg/dL", "2026-03-01T00:00:00Z"), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].ids.sort()).toEqual(["a", "b", "c"]); + }); + + it("uses the most-recently-taken reading for the canonical name/unit/range", () => { + // Same analyte key ("ldl"), case + unit + range drift across reports — + // the newest report's spelling/unit/range wins. + const groups = groupRowsByAnalyte([ + row("old", "ldl", "mg/dl", "2026-01-01T00:00:00Z", { high: 100 }), + row("new", "LDL", "mg/dL", "2026-06-01T00:00:00Z", { high: 116 }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe("LDL"); + expect(groups[0].unit).toBe("mg/dL"); + expect(groups[0].referenceHigh).toBe(116); + }); + + it("keeps distinct analytes in separate groups", () => { + const groups = groupRowsByAnalyte([ + row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"), + row("b", "HDL", "mg/dL", "2026-01-01T00:00:00Z"), + ]); + expect(groups).toHaveLength(2); + }); + + it("skips a row with a blank analyte", () => { + const groups = groupRowsByAnalyte([ + row("a", " ", "mg/dL", "2026-01-01T00:00:00Z"), + row("b", "LDL", "mg/dL", "2026-01-01T00:00:00Z"), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe("LDL"); + }); +}); diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts new file mode 100644 index 000000000..a9cd8d6bb --- /dev/null +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -0,0 +1,217 @@ +/** + * v1.18.1 — one-shot backfill that links legacy `LabResult` rows to a + * user-scoped `Biomarker` catalog entry. + * + * Before the catalog existed, every lab reading carried its own free-text + * `analyte` / `unit` / `reference*`. Recording "LDL" three times forked the + * "same" marker into "LDL" / "ldl" / "LDL-C" with possibly diverging units + * and ranges. This pass heals that at the source: for each user, it groups + * every un-linked live reading by `lower(analyte)`, creates ONE `Biomarker` + * per group (using the most-recently-taken reading's spelling / unit / + * reference bounds — "last lab report wins"), and stamps `biomarkerId` on + * every row in the group. + * + * Idempotent across reboots: the discovery predicate is "the user has at + * least one live reading with `biomarkerId IS NULL`". A completed pass leaves + * every reading linked, so the user drops out of the discovery set. A reboot + * mid-pass re-runs from scratch — `Biomarker` creation upserts on + * `(userId, name)` and the row-link `updateMany` is `biomarkerId: null`-gated, + * so the result converges. A reading whose marker already exists (e.g. the + * user defined it by hand) re-uses that row rather than minting a duplicate. + * + * Modelled on `sleep-timeline-backfill.ts` / `whoop-backfill.ts`. The queue + * name MUST be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts` + * or pg-boss never provisions it and the boot enqueue silently never drains + * (the v1.4.37 dead-queue class). + */ +import { prisma } from "@/lib/db"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill"; + +/** + * Serial concurrency — the per-user pass is a short burst of grouped writes; + * concurrency-1 keeps it off the request pool, matching the other backfill + * queues. + */ +export const LAB_BIOMARKER_BACKFILL_CONCURRENCY = 1; + +export interface LabBiomarkerBackfillPayload { + userId: string; + enqueuedAt: string; +} + +/** A single legacy reading the grouping reads. */ +interface LegacyRow { + id: string; + analyte: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + takenAt: Date; +} + +interface AnalyteGroup { + /** Canonical spelling = the most-recently-taken reading's `analyte`. */ + name: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + ids: string[]; +} + +/** + * Reduce the user's un-linked rows into one group per `lower(analyte)`. The + * canonical name / unit / range come from the most-recently-TAKEN reading in + * each group ("last lab report wins"); every row id rides into the group so a + * single `updateMany` can link them. Exported for unit testing. + */ +export function groupRowsByAnalyte(rows: LegacyRow[]): AnalyteGroup[] { + const byKey = new Map(); + for (const r of rows) { + const key = r.analyte.trim().toLowerCase(); + if (!key) continue; + const bucket = byKey.get(key); + if (bucket) bucket.push(r); + else byKey.set(key, [r]); + } + + const groups: AnalyteGroup[] = []; + for (const bucket of byKey.values()) { + // Newest reading defines the canonical name / unit / range. + const newest = bucket.reduce((a, b) => + b.takenAt.getTime() >= a.takenAt.getTime() ? b : a, + ); + groups.push({ + name: newest.analyte.trim(), + unit: newest.unit, + referenceLow: newest.referenceLow, + referenceHigh: newest.referenceHigh, + panel: newest.panel, + ids: bucket.map((r) => r.id), + }); + } + return groups; +} + +/** + * Backfill one user: group un-linked live readings, create/reuse a Biomarker + * per group, link the rows. Returns the count of markers touched + rows + * linked. + */ +export async function runLabBiomarkerBackfillForUser( + userId: string, +): Promise<{ markers: number; linked: number }> { + const rows = await prisma.labResult.findMany({ + where: { userId, deletedAt: null, biomarkerId: null }, + select: { + id: true, + analyte: true, + unit: true, + referenceLow: true, + referenceHigh: true, + panel: true, + takenAt: true, + }, + }); + + if (rows.length === 0) { + return { markers: 0, linked: 0 }; + } + + const groups = groupRowsByAnalyte(rows); + + let markers = 0; + let linked = 0; + for (const group of groups) { + // Re-use a hand-defined marker of the same name if one exists; otherwise + // mint it. `(userId, name)` is unique, so a concurrent re-run converges. + const biomarker = await prisma.biomarker.upsert({ + where: { userId_name: { userId, name: group.name } }, + // An existing marker keeps its own (possibly user-tuned) unit / range. + update: {}, + create: { + userId, + name: group.name, + unit: group.unit, + lowerBound: group.referenceLow, + upperBound: group.referenceHigh, + panel: group.panel, + }, + }); + markers += 1; + + const { count } = await prisma.labResult.updateMany({ + where: { id: { in: group.ids }, userId, biomarkerId: null }, + data: { biomarkerId: biomarker.id }, + }); + linked += count; + } + + annotate({ + action: { + name: "labs.biomarker.backfill.complete", + details: { markers, linked }, + }, + }); + return { markers, linked }; +} + +/** + * Boot-time discovery. Finds every user holding at least one un-linked live + * lab reading and enqueues one job each. Idempotent across reboots: a + * completed pass links every reading, dropping the user from the discovery + * set. `singletonKey` coalesces duplicate sends. + * + * Best-effort: errors come back through the result value so worker boot never + * fails because of a backfill miss. + */ +export async function enqueueBootTimeLabBiomarkerBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const rows = await prisma.labResult.findMany({ + where: { deletedAt: null, biomarkerId: null }, + select: { userId: true }, + distinct: ["userId"], + }); + + if (rows.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { userId } of rows) { + const payload: LabBiomarkerBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(LAB_BIOMARKER_BACKFILL_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `lab-biomarker-backfill|${userId}`, + }); + if (jobId) enqueued += 1; + else skipped += 1; + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index db6b4d5b2..672614d04 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -28,6 +28,13 @@ import { enqueueBootTimeSleepTimelineBackfill, type SleepTimelineBackfillPayload, } from "@/lib/jobs/sleep-timeline-backfill"; +import { + LAB_BIOMARKER_BACKFILL_QUEUE, + LAB_BIOMARKER_BACKFILL_CONCURRENCY, + runLabBiomarkerBackfillForUser, + enqueueBootTimeLabBiomarkerBackfill, + type LabBiomarkerBackfillPayload, +} from "@/lib/jobs/lab-biomarker-backfill"; import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status"; import { setGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -656,6 +663,11 @@ export async function startReminderWorker() { // pg-boss never provisions it and the boot enqueue silently never drains // (the v1.4.37 dead-queue class). SLEEP_TIMELINE_BACKFILL_QUEUE, + // v1.18.1 — one-shot backfill that links legacy free-text lab readings to + // a user-scoped Biomarker catalog entry (group by `lower(analyte)`). The + // queue MUST be registered here or pg-boss never provisions it and the + // boot enqueue silently never drains (the v1.4.37 dead-queue class). + LAB_BIOMARKER_BACKFILL_QUEUE, // v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no // backfill queue — the hourly window walks the recent SGV set). The queue // MUST be registered here or pg-boss never provisions it and the schedule @@ -1114,6 +1126,25 @@ export async function startReminderWorker() { } }, ); + // v1.18.1 — one-shot lab-biomarker backfill. The boot enqueue below sends + // one job per user holding un-linked free-text lab readings; this handler + // groups them by `lower(analyte)`, mints/reuses a Biomarker per group, and + // links the rows. Idempotent — a re-run links only what is still un-linked. + await boss.work( + LAB_BIOMARKER_BACKFILL_QUEUE, + { localConcurrency: LAB_BIOMARKER_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + const { markers, linked } = + await runLabBiomarkerBackfillForUser(userId); + workerLog( + "info", + `[lab-biomarker-backfill] user=${userId} markers=${markers} linked=${linked}`, + ); + } + }, + ); // v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no // `userId`) walks every configured instance; one user's unreachable host is // warned, not fatal. @@ -1977,6 +2008,33 @@ export async function startReminderWorker() { ); } + // v1.18.1 — fire-and-forget boot discovery for the one-shot lab-biomarker + // backfill. Finds every user holding an un-linked live lab reading and + // enqueues one job each. Idempotent across reboots: a completed pass links + // every reading, dropping the user from the discovery set. Errors come back + // through the helper's result value — worker boot never fails on a miss. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeLabBiomarkerBackfill(); + if (error) { + workerLog( + "error", + `[lab-biomarker-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[lab-biomarker-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[lab-biomarker-backfill] boot discovery threw an unexpected error", + err, + ); + } + // v1.7.0 — fire-and-forget boot discovery for the daily-mean // consolidation pass. Finds every user holding live per-sample // high-frequency mean-type rows and enqueues one job per account. From 04c95e5ba1ba7d93febf22b9ad5526f180e406ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:06:08 +0200 Subject: [PATCH 3/4] feat(labs): structured entry, per-biomarker chart, and edit/delete UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Labs surface to match the rest of the app: - Add a biomarker manager on the Labs page (define/edit/delete markers, seed from a common panel) — the catalog lives with Labs, not Settings. - Replace the free-text add form with a structured one: pick a biomarker, enter the value against its known unit/range; '+ define new' inline. - New /labs/[biomarkerId] detail page with a proper dashboard-style chart (Recharts line + shaded reference band + dashed bounds, range tabs, tooltip) replacing the 72x20 sparkline, plus the reading history with full edit/amend/delete and a delete-Undo toast. - List cards group by linked marker and deep-link to the detail chart. The reference band uses a muted token fill, never an alarm colour; the neutral ReferenceRangeBadge is reused for the in/out-of-range verdict. --- src/app/labs/[biomarkerId]/page.tsx | 51 ++++ src/app/labs/page.tsx | 41 ++- src/components/labs/biomarker-form.tsx | 281 ++++++++++++++++++ src/components/labs/biomarker-manager.tsx | 177 +++++++++++ src/components/labs/lab-biomarker-chart.tsx | 224 ++++++++++++++ src/components/labs/lab-biomarker-detail.tsx | 181 ++++++++++++ src/components/labs/lab-form.tsx | 295 +++++++++++-------- src/components/labs/lab-history-list.tsx | 271 +++++++++++++++++ src/components/labs/lab-list.tsx | 196 ++++++------ src/components/labs/types.ts | 25 ++ 10 files changed, 1514 insertions(+), 228 deletions(-) create mode 100644 src/app/labs/[biomarkerId]/page.tsx create mode 100644 src/components/labs/biomarker-form.tsx create mode 100644 src/components/labs/biomarker-manager.tsx create mode 100644 src/components/labs/lab-biomarker-chart.tsx create mode 100644 src/components/labs/lab-biomarker-detail.tsx create mode 100644 src/components/labs/lab-history-list.tsx diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx new file mode 100644 index 000000000..29189ca39 --- /dev/null +++ b/src/app/labs/[biomarkerId]/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`). + * + * Owns auth + the back link and hands the marker id to + * ``, which carries the heading, the dashboard-style + * chart with the reference band, and the editable reading history. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function LabBiomarkerDetailPage({ + params, +}: { + params: Promise<{ biomarkerId: string }>; +}) { + const { biomarkerId } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 2030d0510..d0b685f1c 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Plus } from "lucide-react"; +import { FlaskConical, Loader2, Plus } from "lucide-react"; +import { BiomarkerManager } from "@/components/labs/biomarker-manager"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { Button } from "@/components/ui/button"; @@ -23,6 +24,7 @@ export default function LabsPage() { const queryClient = useQueryClient(); const { t } = useTranslations(); const [dialogOpen, setDialogOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -30,7 +32,7 @@ export default function LabsPage() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen, + disabled: dialogOpen || manageOpen, }); useEffect(() => { @@ -59,13 +61,25 @@ export default function LabsPage() { {t("labs.subtitle")}

- +
+ + +
+ + + + setDialogOpen(true)} /> ); diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx new file mode 100644 index 000000000..e814ef717 --- /dev/null +++ b/src/components/labs/biomarker-form.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { + BIOMARKER_CATALOG, + BIOMARKER_PANELS, +} from "@/lib/labs/biomarker-catalog"; +import { useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerDto } from "./types"; + +const CONTEXT_MAX_LENGTH = 2000; + +/** Parse a free-text decimal that may use a comma separator. */ +function parseDecimal(raw: string): number | null { + const trimmed = raw.trim().replace(",", "."); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; +} + +interface BiomarkerFormProps { + /** When set the form edits this marker; otherwise it defines a new one. */ + existing?: BiomarkerDto; + onSuccess?: (saved: BiomarkerDto) => void; + onCancel?: () => void; +} + +/** + * v1.18.1 — define or edit a Biomarker catalog entry. + * + * A marker is the Labs feature's reusable definition: name, unit, reference + * bounds (set ONCE, never re-entered per reading), and an optional context + * note. When defining a new marker the user can seed the fields from the + * common-panel catalog (a ` + + + + + {BIOMARKER_PANELS.map((panelKey) => { + const items = BIOMARKER_CATALOG.filter( + (s) => s.panel === panelKey, + ); + if (items.length === 0) return null; + return ( + + + {t(`labs.catalog.panel.${panelKey}`)} + + {items.map((s) => ( + + {t(`labs.catalog.${s.slug}`)} + + ))} + + ); + })} + + +

+ {t("labs.biomarker.form.seedHint")} +

+ + ) : null} + +
+
+ + setName(e.target.value)} + placeholder={t("labs.biomarker.form.namePlaceholder")} + maxLength={120} + autoFocus + required + /> +
+
+ + setUnit(e.target.value)} + placeholder={t("labs.biomarker.form.unitPlaceholder")} + maxLength={40} + required + /> +
+
+ + setPanel(e.target.value)} + placeholder={t("labs.biomarker.form.panelPlaceholder")} + maxLength={120} + /> +
+
+ + setLower(e.target.value)} + placeholder={t("labs.biomarker.form.lowerPlaceholder")} + /> +
+
+ + setUpper(e.target.value)} + placeholder={t("labs.biomarker.form.upperPlaceholder")} + /> +
+
+ +