diff --git a/messages/de.json b/messages/de.json
index b4a1b6762..3f912787b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6723,7 +6723,9 @@
"electrolytes": "Elektrolyte",
"bloodCount": "Blutbild"
}
- }
+ },
+ "noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
+ "showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt"
},
"modules": {
"cycle": {
diff --git a/messages/en.json b/messages/en.json
index b06d7820e..e43d7cef3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6723,7 +6723,9 @@
"electrolytes": "Electrolytes",
"bloodCount": "Blood count"
}
- }
+ },
+ "noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
+ "showingLatestOf": "Showing the latest {shown} of {total} readings"
},
"modules": {
"cycle": {
diff --git a/messages/es.json b/messages/es.json
index da5363bcd..0a8744be9 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6723,7 +6723,9 @@
"electrolytes": "Electrolitos",
"bloodCount": "Hemograma"
}
- }
+ },
+ "noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
+ "showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}"
},
"modules": {
"cycle": {
diff --git a/messages/fr.json b/messages/fr.json
index c8619c81a..d91c5c4f2 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6723,7 +6723,9 @@
"electrolytes": "Électrolytes",
"bloodCount": "Numération sanguine"
}
- }
+ },
+ "noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
+ "showingLatestOf": "Affichage des {shown} derniers résultats sur {total}"
},
"modules": {
"cycle": {
diff --git a/messages/it.json b/messages/it.json
index 465be33dd..135e004ce 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6723,7 +6723,9 @@
"electrolytes": "Elettroliti",
"bloodCount": "Emocromo"
}
- }
+ },
+ "noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
+ "showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}"
},
"modules": {
"cycle": {
diff --git a/messages/pl.json b/messages/pl.json
index 8ea88fdb7..359b2a81d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6723,7 +6723,9 @@
"electrolytes": "Elektrolity",
"bloodCount": "Morfologia krwi"
}
- }
+ },
+ "noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
+ "showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników"
},
"modules": {
"cycle": {
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/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/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/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/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