diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 485c63c0a..5afb9ac69 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -13,6 +13,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { type ResolvedBiomarker, serialiseLabResult, @@ -27,6 +28,13 @@ import { /** * v1.17.1 — structured lab-result store (`/api/labs`). * + * Module gate: unlike `/api/illness/*` (born-gated, every route 403s when the + * module is off), Labs is intentionally NOT server-gated. The Labs module + * toggle is a UX-only nav/visibility preference — the data is always + * owner-scoped and safe to read/write, and the Vorsorge lab-panel reminder + * + doctor-report PDF read these rows regardless of the nav toggle. Gating + * here would 403 those legitimate cross-feature reads. Deliberate opt-out. + * * 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` @@ -36,8 +44,12 @@ import { * * 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. + * reference range FROM the biomarker (server-authoritative). A free-text body + * (no `biomarkerId`) resolves-or-mints a catalog marker by + * `(userId, lower(analyte))`, so NO row ever persists unlinked — every reading + * is editable + detail-navigable, and the boot-time backfill becomes a pure + * pre-upgrade migration. The web + iOS clients render the resolved DTO and + * never recompute. */ /** Map the joined biomarker (or null) into the resolver's shape. */ @@ -168,8 +180,13 @@ async function postLabResult(request: NextRequest) { note, } = parsed.data; - // 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. + // Every row links a catalog marker — no `LabResult` ever persists unlinked + // (v1.18.1 High). Two paths converge on a `biomarker`: + // - Structured: a `biomarkerId` is supplied; resolve + verify ownership + // (a forged / foreign id is a 404). + // - Free-text: no `biomarkerId`; resolve-or-mint the marker by + // `(userId, lower(analyte))` so the legacy quick-capture path still + // yields a linked, editable, detail-navigable reading. let biomarker: ResolvedBiomarker | null = null; if (biomarkerId) { const found = await prisma.biomarker.findFirst({ @@ -187,24 +204,30 @@ async function postLabResult(request: NextRequest) { return apiError("Biomarker not found", 404); } biomarker = found; + } else { + biomarker = await resolveOrMintBiomarker(user.id, { + analyte: analyte as string, + unit: unit as string, + referenceLow: referenceLow ?? null, + referenceHigh: referenceHigh ?? null, + panel: panel ?? null, + }); } - // 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`. + // Field-by-field assignment — never spread `parsed.data`. 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, - biomarkerId: biomarker?.id ?? null, - panel: biomarker ? biomarker.panel : (panel ?? null), - analyte: biomarker ? biomarker.name : (analyte as string), + biomarkerId: biomarker.id, + panel: biomarker.panel, + analyte: biomarker.name, value, - unit: biomarker ? biomarker.unit : (unit as string), - referenceLow: biomarker ? biomarker.lowerBound : (referenceLow ?? null), - referenceHigh: biomarker - ? biomarker.upperBound - : (referenceHigh ?? null), + unit: biomarker.unit, + referenceLow: biomarker.lowerBound, + referenceHigh: biomarker.upperBound, takenAt, source: "MANUAL", noteEncrypted: note ? encryptNoteToBytes(note) : null, @@ -219,7 +242,13 @@ async function postLabResult(request: NextRequest) { annotate({ action: { name: "labs.create" }, - meta: { labResultId: created.id, structured: biomarker !== null }, + meta: { + labResultId: created.id, + biomarkerId: biomarker.id, + // Whether the client picked a catalog marker (structured) vs the + // free-text path that resolved-or-minted one server-side. + structured: biomarkerId !== undefined, + }, }); // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just diff --git a/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts new file mode 100644 index 000000000..e9cbbb0c0 --- /dev/null +++ b/src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts @@ -0,0 +1,141 @@ +/** + * v1.18.1 — `resolveOrMintBiomarker`: the auto-link-on-write helper. + * + * Every free-text lab write resolves (or mints) a user-scoped Biomarker by + * `(userId, lower(analyte))` so NO `LabResult` row ever persists unlinked. + * Asserts: case-insensitive reuse, mint-on-miss from the reading's own + * spelling, no silent rewrite of an existing marker, and the concurrent + * unique-violation race adopting the winner. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + biomarker: { findFirst: vi.fn(), create: vi.fn() }, +})); + +vi.mock("@/lib/db", () => ({ prisma: db })); + +import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; + +const baseInput = { + analyte: "LDL", + unit: "mg/dL", + referenceLow: null, + referenceHigh: 116 as number | null, + panel: "lipids" as string | null, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveOrMintBiomarker", () => { + it("reuses an existing marker (case-insensitive) without minting", async () => { + db.biomarker.findFirst.mockResolvedValue({ + id: "bm_existing", + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: null, + upperBound: 100, + panel: "lipids", + }); + + const out = await resolveOrMintBiomarker("u1", { + ...baseInput, + analyte: "ldl", // lowercase free-text spelling + }); + + expect(out.id).toBe("bm_existing"); + // Existing marker keeps its own unit/range — the incoming free-text + // values do NOT rewrite it. + expect(out.upperBound).toBe(100); + expect(db.biomarker.create).not.toHaveBeenCalled(); + // Match is case-insensitive on the catalog identity. + const where = db.biomarker.findFirst.mock.calls[0][0].where; + expect(where.name).toEqual({ equals: "ldl", mode: "insensitive" }); + expect(where.userId).toBe("u1"); + }); + + it("mints a new marker from the reading when none exists", async () => { + db.biomarker.findFirst.mockResolvedValue(null); + db.biomarker.create.mockResolvedValue({ + id: "bm_new", + name: "LDL", + unit: "mg/dL", + lowerBound: null, + upperBound: 116, + panel: "lipids", + }); + + const out = await resolveOrMintBiomarker("u1", baseInput); + + expect(out.id).toBe("bm_new"); + const data = db.biomarker.create.mock.calls[0][0].data; + expect(data).toMatchObject({ + userId: "u1", + name: "LDL", + unit: "mg/dL", + lowerBound: null, + upperBound: 116, + panel: "lipids", + }); + }); + + it("trims the analyte before minting", async () => { + db.biomarker.findFirst.mockResolvedValue(null); + db.biomarker.create.mockResolvedValue({ + id: "bm_x", + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 400, + panel: null, + }); + + await resolveOrMintBiomarker("u1", { + analyte: " Ferritin ", + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 400, + panel: null, + }); + + expect(db.biomarker.create.mock.calls[0][0].data.name).toBe("Ferritin"); + }); + + it("adopts the race winner on a concurrent unique violation", async () => { + // First lookup misses; create throws (the other writer won the unique + // index); the re-lookup finds the winner. + db.biomarker.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "bm_winner", + name: "TSH", + unit: "mIU/L", + lowerBound: 0.4, + upperBound: 4, + panel: "thyroid", + }); + db.biomarker.create.mockRejectedValue(new Error("unique constraint")); + + const out = await resolveOrMintBiomarker("u1", { + analyte: "TSH", + unit: "mIU/L", + referenceLow: 0.4, + referenceHigh: 4, + panel: "thyroid", + }); + + expect(out.id).toBe("bm_winner"); + expect(db.biomarker.findFirst).toHaveBeenCalledTimes(2); + }); + + it("throws when the create fails and no race winner exists", async () => { + db.biomarker.findFirst.mockResolvedValue(null); + db.biomarker.create.mockRejectedValue(new Error("db down")); + + await expect( + resolveOrMintBiomarker("u1", baseInput), + ).rejects.toThrow(/Failed to resolve biomarker/); + }); +}); diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index 17a2b2234..dd2f3cdf6 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -12,6 +12,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ export function encryptContextToBytes( @@ -28,3 +29,96 @@ export function encryptContextToBytes( export function decryptContextFromBytes(buf: Uint8Array): string { return decrypt(Buffer.from(buf).toString("utf8")); } + +/** Decrypt a stored `Bytes` context note, fail-soft to `null` on any error. + * + * Mirrors `decryptNoteSoft` in the illness DTO layer: a single bad-key / + * malformed row must not 500 the whole catalog list. The single-resource GET + * path uses the throwing `decryptContextFromBytes` so a genuine decrypt + * failure there surfaces instead of silently masking a key-rotation gap. */ +export function decryptContextSoft(buf: Uint8Array | null): string | null { + if (!buf) return null; + try { + return decryptContextFromBytes(buf); + } catch { + return null; + } +} + +/** The minimal Biomarker shape the lab-write resolver returns. */ +export interface MintedBiomarker { + id: string; + name: string; + unit: string; + lowerBound: number | null; + upperBound: number | null; + panel: string | null; +} + +/** + * v1.18.1 — resolve (or mint) the user-scoped Biomarker for a free-text lab + * write so NO `LabResult` row ever persists unlinked. + * + * The catalog identity is `(userId, lower(analyte))`: a reading for "LDL", + * "ldl", and "LDL " all resolve to one marker. An existing marker (hand- + * defined or previously minted) is reused as-is — its unit / range are + * authoritative and the incoming free-text unit / bounds are NOT allowed to + * silently rewrite it. When no marker exists yet, one is minted from the + * reading's own free-text spelling / unit / bounds. The `@@unique([userId, + * name])` index is the structural backstop; a concurrent double-write + * converges via the catch-and-refetch. + * + * Turning the boot-time backfill into a pure historical migration: from + * v1.18.1 on, every write goes through here, so the only unlinked rows that + * can exist are pre-upgrade ones the backfill heals. + */ +export async function resolveOrMintBiomarker( + userId: string, + input: { + analyte: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + }, +): Promise { + const name = input.analyte.trim(); + const select = { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + } as const; + + // Case-insensitive match on the catalog identity. Postgres is the store; + // `mode: "insensitive"` keeps "LDL" === "ldl" without a functional index. + const existing = await prisma.biomarker.findFirst({ + where: { userId, name: { equals: name, mode: "insensitive" } }, + select, + }); + if (existing) return existing; + + try { + return await prisma.biomarker.create({ + data: { + userId, + name, + unit: input.unit, + lowerBound: input.referenceLow, + upperBound: input.referenceHigh, + panel: input.panel, + }, + select, + }); + } catch { + // A concurrent write minted the same `(userId, name)` first — adopt it. + const raced = await prisma.biomarker.findFirst({ + where: { userId, name: { equals: name, mode: "insensitive" } }, + select, + }); + if (raced) return raced; + throw new Error("Failed to resolve biomarker for lab write"); + } +}