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 `