fix(labs): auto-link lab readings to a biomarker on write

A free-text lab write resolves or mints a user-scoped biomarker by
(userId, lower(analyte)) so no LabResult row ever persists unlinked. An
existing marker is reused as-is (its unit/range stay authoritative); a
miss mints one from the reading's own spelling. Every reading is now
editable and detail-navigable, and the boot-time backfill becomes a pure
pre-upgrade migration. Closes the unlinked-row dead-end and the contract
hole in one path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:11:28 +02:00
1 parent b28fa4d4a5
commit 796f6bacd6
3 files changed
+281 -17

No files matched your search

+46 -17
View File
@@ -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