mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
90 lines
2.7 KiB
TypeScript
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>;
|