From 796f6bacd63827fad3aa290bf5372c8e00c8c724 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 16 Jun 2026 23:11:28 +0200
Subject: [PATCH 1/6] 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.
---
src/app/api/labs/route.ts | 63 +++++---
.../resolve-or-mint-biomarker.test.ts | 141 ++++++++++++++++++
src/lib/labs/biomarker-store.ts | 94 ++++++++++++
3 files changed, 281 insertions(+), 17 deletions(-)
create mode 100644 src/lib/labs/__tests__/resolve-or-mint-biomarker.test.ts
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");
+ }
+}
From d1fde96e661de3466425ba944e97cd28f43d3279 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 16 Jun 2026 23:11:35 +0200
Subject: [PATCH 2/6] fix(labs): preserve a reading's note when its decrypted
load fails
The edit sheet loaded the decrypted note from the single-resource GET and
sent note:null on a load miss, silently wiping a note that simply could
not be read. The submit path now omits the note key entirely in that case
(server preserves the stored ciphertext), disables the textarea, and shows
a hint. Decision encoded in a pure resolveNoteForUpdate helper with tests.
---
src/components/labs/lab-history-list.tsx | 42 +++++++++++---
src/lib/labs/__tests__/note-update.test.ts | 66 ++++++++++++++++++++++
src/lib/labs/note-update.ts | 24 ++++++++
3 files changed, 124 insertions(+), 8 deletions(-)
create mode 100644 src/lib/labs/__tests__/note-update.test.ts
create mode 100644 src/lib/labs/note-update.ts
diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx
index cdbeb0b0c..8254f1429 100644
--- a/src/components/labs/lab-history-list.tsx
+++ b/src/components/labs/lab-history-list.tsx
@@ -15,6 +15,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
+import { resolveNoteForUpdate } from "@/lib/labs/note-update";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -39,9 +40,10 @@ function parseDecimal(raw: string): number | null {
/**
* v1.18.1 — reverse-chronological reading history for one biomarker, with
- * full edit / amend / delete (L5). Edit opens a sheet that loads the
- * decrypted note from the single-resource GET; delete soft-deletes with an
- * Undo toast that restores via `/api/labs/restore` (the measurement pattern).
+ * full edit + delete (L5). Edit opens a sheet that loads the decrypted note
+ * from the single-resource GET; delete soft-deletes with an Undo toast that
+ * restores via `/api/labs/restore` (the measurement pattern). An edit
+ * overwrites in place — there is no append-only amendment trail.
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
@@ -53,6 +55,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const [editNote, setEditNote] = useState("");
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState(null);
+ // True when the decrypted-note GET failed to load for the row being edited.
+ // We must NOT send `note: null` in that case — an empty editor then would
+ // wipe a note we simply couldn't read. Instead the PUT omits `note` so the
+ // server preserves the stored ciphertext untouched.
+ const [noteLoadFailed, setNoteLoadFailed] = useState(false);
+ const [hasNote, setHasNote] = useState(false);
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
@@ -87,12 +95,14 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
id: string;
value: number;
takenAt: string;
- note: string | null;
+ // `undefined` → omit `note` from the PUT (leave the stored note
+ // untouched); `null` → clear it; a string → set it.
+ note: string | null | undefined;
}) =>
apiPut(`/api/labs/${body.id}`, {
value: body.value,
takenAt: body.takenAt,
- note: body.note,
+ ...(body.note === undefined ? {} : { note: body.note }),
}),
onSuccess: () => {
invalidate();
@@ -112,6 +122,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditError(null);
+ setNoteLoadFailed(false);
+ setHasNote(reading.hasNote);
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
@@ -120,7 +132,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
);
setEditNote(detail.note ?? "");
} catch {
- /* leave note blank on a load miss */
+ // The note couldn't be loaded. Flag it so the submit path omits `note`
+ // (preserving the stored ciphertext) rather than sending `null` and
+ // wiping a note we just failed to read.
+ setNoteLoadFailed(true);
} finally {
setEditLoading(false);
}
@@ -149,7 +164,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
id: editingId,
value: numericValue,
takenAt: takenDate.toISOString(),
- note: editNote.trim() ? editNote.trim() : null,
+ // `undefined` → omit `note` (server preserves the stored note when its
+ // decrypted load failed); `null` → clear; text → set.
+ note: resolveNoteForUpdate({
+ noteLoadFailed,
+ hadNote: hasNote,
+ editorValue: editNote,
+ }),
});
}
@@ -240,8 +261,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
onChange={(e) => setEditNote(e.target.value)}
maxLength={NOTE_MAX_LENGTH}
rows={2}
- disabled={editLoading}
+ disabled={editLoading || (noteLoadFailed && hasNote)}
/>
+ {noteLoadFailed && hasNote ? (
+
+ {t("labs.noteLoadFailed")}
+
+ ) : null}
{editError ? (
diff --git a/src/lib/labs/__tests__/note-update.test.ts b/src/lib/labs/__tests__/note-update.test.ts
new file mode 100644
index 000000000..646e0247b
--- /dev/null
+++ b/src/lib/labs/__tests__/note-update.test.ts
@@ -0,0 +1,66 @@
+/**
+ * v1.18.1 — the lab-edit note-resolution guard.
+ *
+ * Core invariant: a failed decrypted-note load must NEVER send `note: null`
+ * for a row that had a note — that would silently wipe it. The submit path
+ * omits `note` (→ `undefined`) in that case so the server preserves it.
+ */
+import { describe, expect, it } from "vitest";
+
+import { resolveNoteForUpdate } from "@/lib/labs/note-update";
+
+describe("resolveNoteForUpdate", () => {
+ it("omits note (undefined) when the load failed and the row had a note", () => {
+ expect(
+ resolveNoteForUpdate({
+ noteLoadFailed: true,
+ hadNote: true,
+ editorValue: "",
+ }),
+ ).toBeUndefined();
+ });
+
+ it("clears (null) when the load failed but the row had no note", () => {
+ // No note to preserve → an empty editor legitimately clears.
+ expect(
+ resolveNoteForUpdate({
+ noteLoadFailed: true,
+ hadNote: false,
+ editorValue: "",
+ }),
+ ).toBeNull();
+ });
+
+ it("clears (null) when the editor is emptied on a successful load", () => {
+ expect(
+ resolveNoteForUpdate({
+ noteLoadFailed: false,
+ hadNote: true,
+ editorValue: " ",
+ }),
+ ).toBeNull();
+ });
+
+ it("sets the trimmed text when the editor has content", () => {
+ expect(
+ resolveNoteForUpdate({
+ noteLoadFailed: false,
+ hadNote: true,
+ editorValue: " fasting, morning ",
+ }),
+ ).toBe("fasting, morning");
+ });
+
+ it("always preserves (undefined) when load failed on a row with a note, even with editor content", () => {
+ // The edit sheet disables the note textarea in this state, so the editor
+ // value is the stale-blank initial value. The guard must omit `note`
+ // unconditionally here so a failed-to-decrypt note is never overwritten.
+ expect(
+ resolveNoteForUpdate({
+ noteLoadFailed: true,
+ hadNote: true,
+ editorValue: "stale",
+ }),
+ ).toBeUndefined();
+ });
+});
diff --git a/src/lib/labs/note-update.ts b/src/lib/labs/note-update.ts
new file mode 100644
index 000000000..c123eef5e
--- /dev/null
+++ b/src/lib/labs/note-update.ts
@@ -0,0 +1,24 @@
+/**
+ * v1.18.1 — note-resolution for the lab-reading edit PUT.
+ *
+ * The edit sheet loads a row's decrypted note from the single-resource GET.
+ * If that load FAILS we must never send `note: null` — an empty editor would
+ * then wipe a note we simply couldn't read. This pure helper encodes the
+ * three-way decision the submit path uses:
+ *
+ * - load failed AND the row had a note → `undefined` (omit `note` from the
+ * PUT body so the server preserves the stored ciphertext untouched).
+ * - editor is empty → `null` (clear the note).
+ * - editor has text → the trimmed text (set the note).
+ *
+ * `undefined` is the signal the caller uses to OMIT the key entirely.
+ */
+export function resolveNoteForUpdate(args: {
+ noteLoadFailed: boolean;
+ hadNote: boolean;
+ editorValue: string;
+}): string | null | undefined {
+ if (args.noteLoadFailed && args.hadNote) return undefined;
+ const trimmed = args.editorValue.trim();
+ return trimmed ? trimmed : null;
+}
From 9141b8f74f2ea1603520a21625a36888aa2903e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 16 Jun 2026 23:11:48 +0200
Subject: [PATCH 3/6] fix(labs): guard partial bound edits against an inverted
range
A PUT that moves a single reference bound past the row's stored other
bound slipped through the schema refine (which only fires when both bounds
arrive together). Both the lab-result and biomarker routes now merge the
effective bounds and 422 on low > high. Editing catalog-resolved fields
(analyte/unit/panel/range) on a linked reading is also rejected rather than
silently no-oped. Shared isInvertedRange/effectiveBound helpers with tests.
---
src/app/api/biomarkers/[id]/route.ts | 15 +++++
src/app/api/labs/[id]/route.ts | 42 ++++++++++++-
src/lib/labs/__tests__/inverted-range.test.ts | 59 +++++++++++++++++++
src/lib/validations/labs.ts | 28 +++++++++
4 files changed, 143 insertions(+), 1 deletion(-)
create mode 100644 src/lib/labs/__tests__/inverted-range.test.ts
diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts
index 48be5a921..87ec6f236 100644
--- a/src/app/api/biomarkers/[id]/route.ts
+++ b/src/app/api/biomarkers/[id]/route.ts
@@ -17,6 +17,7 @@ import {
} from "@/lib/labs/biomarker-store";
import { annotate } from "@/lib/logging/context";
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
+import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
/**
* v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`).
@@ -136,6 +137,20 @@ export const PUT = apiHandler(
if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
if (d.upperBound !== undefined) data.upperBound = d.upperBound;
if (d.panel !== undefined) data.panel = d.panel;
+
+ // Inverted-range guard for a PARTIAL bound update. The schema refine only
+ // fires when both bounds arrive together; moving a single bound past the
+ // row's existing other bound would otherwise persist an inverted window.
+ // Merge effective bounds (parsed when present, else stored) and 422 when
+ // both resolve to concrete numbers with low > high.
+ if (
+ isInvertedRange(
+ effectiveBound(d.lowerBound, existing.lowerBound),
+ effectiveBound(d.upperBound, existing.upperBound),
+ )
+ ) {
+ return apiError("lowerBound must not exceed upperBound", 422);
+ }
if (d.context !== undefined) {
data.contextEncrypted = d.context
? encryptContextToBytes(d.context)
diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts
index 9463efa45..39bd061e5 100644
--- a/src/app/api/labs/[id]/route.ts
+++ b/src/app/api/labs/[id]/route.ts
@@ -18,7 +18,11 @@ import {
} from "@/lib/labs/serialise";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
-import { updateLabResultSchema } from "@/lib/validations/labs";
+import {
+ effectiveBound,
+ isInvertedRange,
+ updateLabResultSchema,
+} from "@/lib/validations/labs";
/**
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
@@ -125,6 +129,27 @@ export const PUT = apiHandler(
const d = parsed.data;
+ // A reading linked to a catalog marker resolves its name / unit / range
+ // FROM the biomarker (server-authoritative). Editing those catalog-owned
+ // fields on a linked row is meaningless — they would persist as stale
+ // historical columns the serialiser ignores. Reject the attempt with a
+ // 422 rather than silently no-op, so the client never believes an edit
+ // landed. Edit such fields on the Biomarker itself instead.
+ const isLinked = existing.biomarkerId !== null;
+ if (
+ isLinked &&
+ (d.analyte !== undefined ||
+ d.unit !== undefined ||
+ d.panel !== undefined ||
+ d.referenceLow !== undefined ||
+ d.referenceHigh !== undefined)
+ ) {
+ return apiError(
+ "analyte / unit / panel / reference range are resolved from the linked biomarker and cannot be edited on a linked reading",
+ 422,
+ );
+ }
+
// 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 /
@@ -137,6 +162,21 @@ export const PUT = apiHandler(
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
+
+ // Inverted-range guard for a PARTIAL bound update. The schema-level
+ // refine only fires when both bounds arrive in one request; a request
+ // that moves a single bound below/above the row's existing other bound
+ // would otherwise persist an inverted range. Merge the effective bounds
+ // (parsed value when present, else the stored value) and 422 when both
+ // resolve to concrete numbers with low > high.
+ if (
+ isInvertedRange(
+ effectiveBound(d.referenceLow, existing.referenceLow),
+ effectiveBound(d.referenceHigh, existing.referenceHigh),
+ )
+ ) {
+ return apiError("referenceLow must not exceed referenceHigh", 422);
+ }
if (d.note !== undefined) {
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
diff --git a/src/lib/labs/__tests__/inverted-range.test.ts b/src/lib/labs/__tests__/inverted-range.test.ts
new file mode 100644
index 000000000..9d641c718
--- /dev/null
+++ b/src/lib/labs/__tests__/inverted-range.test.ts
@@ -0,0 +1,59 @@
+/**
+ * v1.18.1 — partial-update inverted-range merge guard.
+ *
+ * The lab + biomarker PUT routes merge a parsed bound against the row's stored
+ * bound before checking low ≤ high, so a single-bound edit cannot persist a
+ * transposed range that the schema-level refine (which only fires when both
+ * bounds arrive together) would miss.
+ */
+import { describe, expect, it } from "vitest";
+
+import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
+
+describe("effectiveBound", () => {
+ it("prefers the parsed value when present", () => {
+ expect(effectiveBound(50, 10)).toBe(50);
+ });
+
+ it("uses an explicit null parsed value (clearing the bound)", () => {
+ expect(effectiveBound(null, 10)).toBeNull();
+ });
+
+ it("falls back to the stored value when the field is omitted", () => {
+ expect(effectiveBound(undefined, 10)).toBe(10);
+ expect(effectiveBound(undefined, null)).toBeNull();
+ });
+});
+
+describe("isInvertedRange", () => {
+ it("flags low > high", () => {
+ expect(isInvertedRange(120, 100)).toBe(true);
+ });
+
+ it("accepts low == high (inclusive bounds)", () => {
+ expect(isInvertedRange(100, 100)).toBe(false);
+ });
+
+ it("accepts a normal range", () => {
+ expect(isInvertedRange(40, 100)).toBe(false);
+ });
+
+ it("ignores an open-ended range (one bound null)", () => {
+ expect(isInvertedRange(null, 100)).toBe(false);
+ expect(isInvertedRange(40, null)).toBe(false);
+ expect(isInvertedRange(null, null)).toBe(false);
+ });
+
+ it("catches the partial-update case: new low above the stored high", () => {
+ // PUT sets referenceLow=130 on a row whose stored referenceHigh=116.
+ const low = effectiveBound(130, null);
+ const high = effectiveBound(undefined, 116);
+ expect(isInvertedRange(low, high)).toBe(true);
+ });
+
+ it("catches the partial-update case: new high below the stored low", () => {
+ const low = effectiveBound(undefined, 40);
+ const high = effectiveBound(20, null);
+ expect(isInvertedRange(low, high)).toBe(true);
+ });
+});
diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts
index 1bad16de2..56cf1f246 100644
--- a/src/lib/validations/labs.ts
+++ b/src/lib/validations/labs.ts
@@ -144,6 +144,34 @@ export const updateLabResultSchema = z
export type UpdateLabResultInput = z.infer;
+/**
+ * Inverted-range merge guard for a PARTIAL bound update.
+ *
+ * The schema-level `referenceLow <= referenceHigh` refine only fires when both
+ * bounds arrive in the SAME request. A partial PUT that moves a single bound
+ * past the row's existing other bound slips through the schema. The route
+ * resolves the EFFECTIVE bounds (the parsed value when present, else the
+ * stored value) and calls this to reject a transposed window with a 422.
+ *
+ * Returns true when both effective bounds are concrete numbers and low > high.
+ */
+export function isInvertedRange(
+ effectiveLow: number | null,
+ effectiveHigh: number | null,
+): boolean {
+ return (
+ effectiveLow !== null && effectiveHigh !== null && effectiveLow > effectiveHigh
+ );
+}
+
+/** Resolve an effective bound: the parsed value when present, else the stored. */
+export function effectiveBound(
+ parsed: number | null | undefined,
+ stored: number | null,
+): number | null {
+ return parsed !== undefined ? parsed : stored;
+}
+
/** 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).
From 6df0c64d85baedc45983777c6d0a3b6f2738b5d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 16 Jun 2026 23:11:54 +0200
Subject: [PATCH 4/6] fix(labs): fail-soft the biomarker list context decrypt
The catalog GET eagerly threw on the first un-decryptable context row,
500ing the whole list. The list serializer now decrypts fail-soft (null
on a bad-key/malformed row) via decryptContextSoft, bounding the
fail-closed blast radius to one marker; the single-resource GET stays the
authoritative throwing read for the plaintext context.
---
src/app/api/biomarkers/route.ts | 17 ++++++++++++-----
.../labs/__tests__/biomarker-store.test.ts | 19 +++++++++++++++++++
2 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts
index 61a5b2f14..a9c2a7ff5 100644
--- a/src/app/api/biomarkers/route.ts
+++ b/src/app/api/biomarkers/route.ts
@@ -13,7 +13,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import {
- decryptContextFromBytes,
+ decryptContextSoft,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { annotate } from "@/lib/logging/context";
@@ -22,6 +22,12 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
/**
* v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`).
*
+ * Module gate: like `/api/labs/*`, this is intentionally NOT server-gated.
+ * The Labs module toggle is a UX-only nav preference; the data is owner-scoped
+ * and read by cross-feature surfaces (Vorsorge reminder resolution, the
+ * doctor-report PDF) regardless of the toggle. Deliberate opt-out, in contrast
+ * to the born-gated `/api/illness/*` routes.
+ *
* 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.
@@ -33,7 +39,10 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
*/
// The encrypted context bytes are never echoed back; `hasContext` flags
-// presence and the single-resource GET returns the decrypted text.
+// presence. The list path decrypts FAIL-SOFT (`decryptContextSoft` → null on
+// a bad-key / malformed row) so one un-decryptable row never 500s the whole
+// catalog — the single-resource GET is the authoritative read for the
+// plaintext context. This bounds the fail-closed blast radius to one marker.
function serialiseBiomarker(row: {
id: string;
name: string;
@@ -53,9 +62,7 @@ function serialiseBiomarker(row: {
upperBound: row.upperBound,
panel: row.panel,
hasContext: row.contextEncrypted !== null,
- context: row.contextEncrypted
- ? decryptContextFromBytes(row.contextEncrypted)
- : null,
+ context: decryptContextSoft(row.contextEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
diff --git a/src/lib/labs/__tests__/biomarker-store.test.ts b/src/lib/labs/__tests__/biomarker-store.test.ts
index fd8b849af..c200f0c45 100644
--- a/src/lib/labs/__tests__/biomarker-store.test.ts
+++ b/src/lib/labs/__tests__/biomarker-store.test.ts
@@ -4,6 +4,7 @@ import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
decryptContextFromBytes,
+ decryptContextSoft,
encryptContextToBytes,
} from "../biomarker-store";
@@ -32,4 +33,22 @@ describe("biomarker context codec", () => {
const asString = Buffer.from(bytes).toString("utf8");
expect(asString).not.toContain(plain);
});
+
+ describe("decryptContextSoft (fail-soft list decrypt)", () => {
+ it("returns null for a null payload", () => {
+ expect(decryptContextSoft(null)).toBeNull();
+ });
+
+ it("round-trips a valid payload like the throwing variant", () => {
+ const bytes = encryptContextToBytes("morning fasting panel");
+ expect(decryptContextSoft(bytes)).toBe("morning fasting panel");
+ });
+
+ it("returns null instead of throwing on a malformed / bad-key row", () => {
+ // A non-ciphertext payload would throw in `decryptContextFromBytes`.
+ const garbage = new Uint8Array(Buffer.from("not-a-ciphertext"));
+ expect(() => decryptContextFromBytes(garbage)).toThrow();
+ expect(decryptContextSoft(garbage)).toBeNull();
+ });
+ });
});
From 9c8c7051aa023a42e5869b7f475ba7819bd338e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 16 Jun 2026 23:12:04 +0200
Subject: [PATCH 5/6] fix(labs): pin add-entry action bars to the sheet footer;
tighten header
The lab and biomarker forms portal their Cancel/Save row into the
ResponsiveSheet sticky footer (footerSlot pattern) so Save stays reachable
on mobile instead of scrolling away. The Labs header folds Manage
biomarkers into an overflow kebab beside the primary Add, mirroring the
medication header. The biomarker detail gains a self-edit Pencil opening
the pre-filled form. Add buttons on the labs detail and measurements page
meet the min-h-11 sm:min-h-9 tap floor.
---
src/app/labs/page.tsx | 46 ++++++++----
src/app/measurements/page.tsx | 5 +-
src/components/labs/biomarker-form.tsx | 58 +++++++++------
src/components/labs/biomarker-manager.tsx | 5 ++
src/components/labs/lab-biomarker-detail.tsx | 74 ++++++++++++++++++--
src/components/labs/lab-form.tsx | 72 +++++++++++++------
6 files changed, 202 insertions(+), 58 deletions(-)
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index d0b685f1c..8ef9835e3 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -3,12 +3,18 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
-import { FlaskConical, Loader2, Plus } from "lucide-react";
+import { FlaskConical, Loader2, MoreVertical, 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";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
@@ -25,6 +31,9 @@ export default function LabsPage() {
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
+ // Sticky-footer slot element for the add-result sheet (filled by the
+ // ResponsiveSheet `footer` ref so the form portals its action row there).
+ const [addFooterEl, setAddFooterEl] = useState(null);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -61,17 +70,7 @@ export default function LabsPage() {
{t("labs.subtitle")}
-
-
+
+ {/* Secondary actions fold into one overflow kebab beside the primary
+ Add, mirroring the medication-card header (one cluster, not a
+ two-button row). */}
+
+
+
+
+
+ setManageOpen(true)}>
+
+ {t("labs.biomarker.manage")}
+
+
+