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(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")} + + +
@@ -87,8 +107,10 @@ export default function LabsPage() { onOpenChange={setDialogOpen} title={t("labs.addResult")} description={t("labs.addDescription")} + footer={
} > { setDialogOpen(false); queryClient.invalidateQueries({ diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index c1dfcfacd..1689ed852 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -120,7 +120,10 @@ export default function MeasurementsPage() { {t("measurements.subtitle")}

- diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index e814ef717..056de1a3c 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -41,6 +42,13 @@ interface BiomarkerFormProps { existing?: BiomarkerDto; onSuccess?: (saved: BiomarkerDto) => void; onCancel?: () => void; + /** + * When mounted inside a `` the caller passes the sheet's + * footer slot element here; the Cancel / Save row portals into it so the + * bottom-sheet branch can sticky-pin it. The Save button stays tied to the + * `
` via the HTML `form` attribute. + */ + footerSlot?: HTMLElement | null; } /** @@ -57,8 +65,10 @@ export function BiomarkerForm({ existing, onSuccess, onCancel, + footerSlot, }: BiomarkerFormProps) { const { t } = useTranslations(); + const formId = useId(); const [name, setName] = useState(existing?.name ?? ""); const [unit, setUnit] = useState(existing?.unit ?? ""); @@ -129,8 +139,29 @@ export function BiomarkerForm({ } } + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + return ( - + {!existing ? (
{latest ? ( @@ -156,6 +189,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {readings.length > 0 ? (

{t("labs.detail.history")}

+ {truncated ? ( +

+ {t("labs.showingLatestOf", { + shown: readings.length, + total, + })} +

+ ) : null} @@ -169,13 +210,36 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onOpenChange={setAddOpen} title={t("labs.addResult")} description={t("labs.addDescription")} + footer={ +
+ } > setAddOpen(false)} /> + + + } + > + {marker ? ( + setEditOpen(false)} + /> + ) : null} +
); } diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 04aebe218..b36ceb170 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -54,6 +55,14 @@ interface LabFormProps { lockedBiomarkerId?: string; onSuccess?: (created: LabResultDto) => void; onCancel?: () => void; + /** + * When mounted inside a `` the caller passes the sheet's + * footer slot element here. The Cancel / Save action row is portalled into + * it so the bottom-sheet branch can sticky-pin it above the keyboard; the + * Save button stays tied to the `
` via the HTML `form` attribute so + * submit-on-Enter and portalled-click both still submit. + */ + footerSlot?: HTMLElement | null; } /** @@ -70,9 +79,11 @@ export function LabForm({ lockedBiomarkerId, onSuccess, onCancel, + footerSlot, }: LabFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const formId = useId(); const { data: catalog, isLoading: catalogLoading } = useQuery({ queryKey: queryKeys.biomarkers(), @@ -88,6 +99,9 @@ export function LabForm({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [defineOpen, setDefineOpen] = useState(false); + const [defineFooterEl, setDefineFooterEl] = useState( + null, + ); const markers = catalog?.biomarkers ?? []; const selected = markers.find((m) => m.id === biomarkerId); @@ -148,9 +162,30 @@ export function LabForm({ ) : ""; + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + return ( <> - +