fix(labs): auto-link biomarker on write, note-preserve, inverted-range guard, footer-pin, fail-soft decrypt, self-edit

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:14:01 +02:00
commit e8e5ece6a1
27 files changed
+824 -103

No files matched your search

+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": {
+15
View File
@@ -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)
+12 -5
View File
@@ -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(),
};
+41 -1
View File
@@ -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;
}
+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
+34 -12
View File
@@ -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<HTMLDivElement | null>(null);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -61,17 +70,7 @@ export default function LabsPage() {
{t("labs.subtitle")}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
onClick={() => setManageOpen(true)}
className="min-h-11 sm:min-h-9"
>
<FlaskConical className="h-4 w-4" />
<span className="hidden sm:inline">
{t("labs.biomarker.manage")}
</span>
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -79,6 +78,27 @@ export default function LabsPage() {
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
{/* Secondary actions fold into one overflow kebab beside the primary
Add, mirroring the medication-card header (one cluster, not a
two-button row). */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => setManageOpen(true)}>
<FlaskConical className="mr-2 h-4 w-4" />
{t("labs.biomarker.manage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -87,8 +107,10 @@ export default function LabsPage() {
onOpenChange={setDialogOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
footer={<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />}
>
<LabForm
footerSlot={addFooterEl}
onSuccess={() => {
setDialogOpen(false);
queryClient.invalidateQueries({
+4 -1
View File
@@ -120,7 +120,10 @@ export default function MeasurementsPage() {
{t("measurements.subtitle")}
</p>
</div>
<Button className="shrink-0" onClick={() => setDialogOpen(true)}>
<Button
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setDialogOpen(true)}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
+38 -20
View File
@@ -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 `<ResponsiveSheet>` 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
* `<form>` 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 ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.biomarker.form.save")}
</Button>
</>
);
return (
<form onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
{!existing ? (
<div className="space-y-1.5">
<Label htmlFor="biomarker-seed">
@@ -258,24 +289,11 @@ export function BiomarkerForm({
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.biomarker.form.save")}
</Button>
</div>
{footerSlot ? (
createPortal(footerNode, footerSlot)
) : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
);
}
@@ -33,6 +33,7 @@ export function BiomarkerManager() {
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -165,9 +166,13 @@ export function BiomarkerManager() {
: t("labs.biomarker.defineTitle")
}
description={t("labs.biomarker.defineDescription")}
footer={
<div ref={setFormFooterEl} className="flex w-full justify-end gap-2" />
}
>
<BiomarkerForm
existing={editing ?? undefined}
footerSlot={formFooterEl}
onSuccess={afterSave}
onCancel={() => setFormOpen(false)}
/>
+69 -5
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Plus } from "lucide-react";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -15,6 +15,7 @@ import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import { LabBiomarkerChart } from "./lab-biomarker-chart";
import { LabForm } from "./lab-form";
import { LabHistoryList } from "./lab-history-list";
@@ -35,6 +36,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
// Sticky-footer slot for the add-value sheet (the form portals here).
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
const { data: marker, isError: markerError } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
@@ -62,6 +67,9 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
});
const readings: LabResultDto[] = list?.results ?? [];
// The reading feed caps at 500 server-side; surface a hint when truncated.
const total = list?.meta?.total ?? 0;
const truncated = total > readings.length;
const latest =
readings.length > 0
? [...readings].sort(
@@ -75,6 +83,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
function afterEditMarker() {
setEditOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
// Resolved name / unit / range on every reading derives from the marker.
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
@@ -105,10 +123,25 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</p>
) : null}
</div>
<Button onClick={() => setAddOpen(true)} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
</div>
</div>
{latest ? (
@@ -156,6 +189,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
{readings.length > 0 ? (
<div className="space-y-2">
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", {
shown: readings.length,
total,
})}
</p>
) : null}
<Card>
<CardContent className="py-0">
<LabHistoryList readings={readings} />
@@ -169,13 +210,36 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onOpenChange={setAddOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
footer={
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
}
>
<LabForm
lockedBiomarkerId={biomarkerId}
footerSlot={addFooterEl}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
/>
</ResponsiveSheet>
<ResponsiveSheet
open={editOpen}
onOpenChange={setEditOpen}
title={t("labs.biomarker.editTitle")}
description={t("labs.biomarker.defineDescription")}
footer={
<div ref={setEditFooterEl} className="flex w-full justify-end gap-2" />
}
>
{marker ? (
<BiomarkerForm
existing={marker}
footerSlot={editFooterEl}
onSuccess={afterEditMarker}
onCancel={() => setEditOpen(false)}
/>
) : null}
</ResponsiveSheet>
</div>
);
}
+52 -20
View File
@@ -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 `<ResponsiveSheet>` 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 `<form>` 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<string | null>(null);
const [defineOpen, setDefineOpen] = useState(false);
const [defineFooterEl, setDefineFooterEl] = useState<HTMLDivElement | null>(
null,
);
const markers = catalog?.biomarkers ?? [];
const selected = markers.find((m) => m.id === biomarkerId);
@@ -148,9 +162,30 @@ export function LabForm({
)
: "";
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</>
);
return (
<>
<form onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
<Select
@@ -239,33 +274,30 @@ export function LabForm({
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</div>
{/* When no footer slot is supplied (rare — e.g. a non-sheet host) the
action row renders inline; inside a sheet it portals into the
sticky footer. */}
{footerSlot ? null : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
{footerSlot ? createPortal(footerNode, footerSlot) : null}
<ResponsiveSheet
open={defineOpen}
onOpenChange={setDefineOpen}
title={t("labs.biomarker.defineTitle")}
description={t("labs.biomarker.defineDescription")}
footer={
<div
ref={setDefineFooterEl}
className="flex w-full justify-end gap-2"
/>
}
>
<BiomarkerForm
footerSlot={defineFooterEl}
onSuccess={afterDefine}
onCancel={() => setDefineOpen(false)}
/>
+34 -8
View File
@@ -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<string | null>(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<LabResultDto>(`/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 ? (
<p className="text-muted-foreground text-xs">
{t("labs.noteLoadFailed")}
</p>
) : null}
</div>
{editError ? (
<p className="text-destructive text-sm" role="alert">
+12
View File
@@ -101,6 +101,13 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
[data?.results],
);
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
// calm "showing latest N of M" hint when the cap truncates so the count is
// never silently wrong — proper cursor paging is a later iteration.
const total = data?.meta?.total ?? 0;
const shown = data?.results?.length ?? 0;
const truncated = total > shown;
if (isLoading) {
return (
<div className="space-y-3" data-slot="lab-list-loading">
@@ -144,6 +151,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
return (
<div className="space-y-3">
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
</p>
) : null}
{groups.map((group) => {
const body = (
<>
+13 -6
View File
@@ -3,12 +3,19 @@
/**
* v1.17.1 — minimal inline trend sparkline for an analyte's readings.
*
* A dependency-free SVG polyline (the project defers Recharts for tiny
* inline trends; this matches the doctor-report PDF's vector-polyline
* approach). Rendered only when an analyte has ≥ 2 readings. Stroke uses
* the neutral `currentColor` so it inherits the calm muted-foreground tone
* of its row — no status colour, in keeping with the no-alarming-colour
* ethos.
* A dependency-free 72×20 SVG polyline. This is DELIBERATELY a different
* implementation from the detail page's Recharts `<LabBiomarkerChart>`: a
* list row renders one tiny, axis-less, tooltip-less, interaction-less trend
* per group (dozens on screen at once), where mounting a Recharts
* `ResponsiveContainer` each would be heavy and visually noisy. The full
* interactive chart — axes, reference band, range tabs, rich tooltip — lives
* only on the single-biomarker detail surface. The same split (inline
* polyline vs Recharts) is used by the doctor-report PDF. The two are not
* drift; do not unify them.
*
* Rendered only when an analyte has ≥ 2 readings. Stroke uses the neutral
* `currentColor` so it inherits the calm muted-foreground tone of its row —
* no status colour, in keeping with the no-alarming-colour ethos.
*
* `values` are passed oldest → newest.
*/
@@ -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();
});
});
});
@@ -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);
});
});
@@ -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();
});
});
@@ -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/);
});
});
-2
View File
@@ -83,5 +83,3 @@ export const BIOMARKER_PANELS = [
"electrolytes",
"bloodCount",
] as const;
export type BiomarkerPanelKey = (typeof BIOMARKER_PANELS)[number];
+94
View File
@@ -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<MintedBiomarker> {
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");
}
}
+24
View File
@@ -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;
}
+28
View File
@@ -144,6 +144,34 @@ export const updateLabResultSchema = z
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
/**
* 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).