Files
HealthLog/src/lib/validations/biomarkers.ts
T
Marc-André Bombeck e9037a0d92 feat(labs): biomarker catalog with server-resolved reference ranges
Add a user-scoped Biomarker catalog so a marker is defined once — name,
unit, lower/upper bound, optional encrypted context, panel — and every
later reading just links it. New /api/biomarkers CRUD plus a structured
/api/labs create path: a biomarkerId resolves the unit + reference range
server-side, so the list and detail DTOs carry the resolved values and
the client never recomputes the range. Lab reads include the linked
marker and fall back to the legacy free-text fields for unlinked rows.

Add a lab-result restore endpoint for the delete-Undo affordance, a
shared serialiser, a static common-panel seed list, and tests for
catalog validation, structured-entry resolution, and the context codec.
2026-06-16 21:05:51 +02:00

90 lines
2.7 KiB
TypeScript

/**
* v1.18.1 — user-scoped Biomarker catalog validation.
*
* A `Biomarker` defines a lab marker ONCE: canonical name, unit, reference
* bounds, and an optional context note. Recording a value later is just
* picking the biomarker — the unit and reference range are resolved
* server-side from the catalog row, never re-entered (ending the per-row
* free-text divergence: "LDL" vs "ldl", `mg/dL` vs `mg/dl`).
*
* The reference window is the lab/sex/age-dependent "normal" range; the
* server's neutral range verdict (`classifyReferenceRange`) is computed
* against these bounds. Both are independently optional (a marker may report
* only an upper bound, e.g. LDL < 116); when both are present the lower must
* not exceed the upper.
*/
import { z } from "zod/v4";
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
const upperBound = z.number().finite().optional();
const optionalContext = z
.string()
.trim()
.max(2000)
.optional()
// An empty / whitespace-only context normalises to "no context" so a
// blank field clears rather than stores an empty string.
.transform((v) => (v && v.length > 0 ? v : undefined));
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
.or(z.literal("").transform(() => undefined));
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
lowerBound,
upperBound,
context: optionalContext,
panel: optionalPanel,
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.upperBound === undefined ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
/**
* Update schema: every field optional (partial edit). `context` / `panel` /
* a bound accept an explicit `null` to clear the stored value — distinct from
* an omitted key, which leaves the column untouched.
*/
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
panel: optionalPanel.or(z.null()),
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.lowerBound === null ||
d.upperBound === undefined ||
d.upperBound === null ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type UpdateBiomarkerInput = z.infer<typeof updateBiomarkerSchema>;