Files
HealthLog/src/lib/validations/labs.ts
T
Marc-André Bombeck a5b3715a46 feat(labs): record qualitative lab results
Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
2026-06-20 11:17:43 +02:00

242 lines
8.5 KiB
TypeScript

/**
* v1.17.1 — structured lab-result store.
*
* A minimal, generic `LabResult` capture path: a self-hoster records a
* biomarker reading (HbA1c, LDL, ferritin, TSH, …) with its value, unit,
* the lab's reference range, and the sample date. Pairs with the v1.17.1
* Vorsorge annual-blood-panel reminder, which otherwise has nowhere to
* record the result it reminds the user to obtain.
*
* The model is intentionally free-form on `analyte` + `unit` (no closed
* LOINC enum): a lab report prints whatever name and unit it likes, and a
* forced taxonomy would reject real-world values. The optional `panel`
* groups analytes that arrived together (e.g. "Großes Blutbild").
*/
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
* Far-past floor for a lab sample. A self-hoster legitimately backdates an
* old paper report, so the window is generous (50 years) but still rejects
* epoch-zero / corrupted-import instants via the shared 1900 floor.
*/
const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */
const takenAtField = validateEntryInstant(
z
.string()
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
.transform((s) => new Date(s)),
{
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
pastMessage: "Sample date is too far in the past",
},
);
/** Trimmed, bounded free-text field with a min-length guard. */
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
// An empty-string panel is normalised to "no panel" so the grouping
// stays NULL-distinct rather than carrying a blank label.
.or(z.literal("").transform(() => undefined));
const optionalNote = z
.string()
.trim()
.max(2000)
.optional()
.or(z.literal("").transform(() => undefined));
/**
* Reference bounds are independently optional (a lab may report only an
* upper bound, e.g. LDL < 116). When BOTH are present the low bound must
* not exceed the high bound — a transposed range is a client error.
*/
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
* from the biomarker server-side, so `analyte` / `unit` may be omitted (the
* route fills them from the catalog row). A free-text reading without a
* catalog link stays valid (quick-capture / legacy path).
*/
const biomarkerId = z.string().trim().min(1).max(64).optional();
/**
* v1.18.9 — a qualitative reading ("negativ" / "positiv" / "grenzwertig" /
* "nicht nachweisbar"). Trimmed, non-empty, sensibly capped. Mutually
* exclusive with the numeric `value` (see the create/update refinements).
*/
const valueText = requiredText(120).optional();
export const createLabResultSchema = z
.object({
biomarkerId,
panel: optionalPanel,
analyte: requiredText(120).optional(),
// v1.18.9 — `value` is no longer required: a reading is EITHER numeric
// (`value`) OR qualitative (`valueText`), never both, never neither.
value: z.number().finite().optional(),
valueText,
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
takenAt: takenAtField,
note: optionalNote,
})
// Either a catalog link OR a free-text analyte must be present.
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {
message: "Either biomarkerId or analyte is required",
path: ["analyte"],
})
// Numeric XOR qualitative: exactly one of `value` / `valueText` is set.
.refine((d) => (d.value !== undefined) !== (d.valueText !== undefined), {
message:
"Provide exactly one of value (numeric) or valueText (qualitative)",
path: ["value"],
})
// A numeric reading without a catalog link still needs a unit (the catalog
// otherwise supplies it server-side). A qualitative reading needs no unit —
// its result is the text, and a unit/range is meaningless for it.
.refine(
(d) =>
d.valueText !== undefined ||
d.biomarkerId !== undefined ||
d.unit !== undefined,
{
message:
"unit is required for a numeric reading when no biomarkerId is given",
path: ["unit"],
},
)
// Reference bounds apply to the numeric path only; skip them entirely for a
// qualitative reading.
.refine(
(d) =>
d.valueText !== undefined ||
d.referenceLow === undefined ||
d.referenceHigh === undefined ||
d.referenceLow <= d.referenceHigh,
{
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
);
export type CreateLabResultInput = z.infer<typeof createLabResultSchema>;
/**
* Update schema: every field optional (partial edit). `panel` and `note`
* accept an explicit `null` to clear the stored value — distinct from an
* omitted key, which leaves the column untouched.
*/
export const updateLabResultSchema = z
.object({
panel: optionalPanel.or(z.null()),
analyte: requiredText(120).optional(),
value: z.number().finite().optional(),
// v1.18.9 — edit the qualitative result. A row stays single-typed: a PUT
// must not set BOTH `value` and `valueText` (the refine below). Switching a
// row's type (numeric ↔ qualitative) is intentionally not supported here —
// delete and re-add — so an omitted key leaves the row's existing type.
valueText: requiredText(120).optional(),
unit: requiredText(40).optional(),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
takenAt: takenAtField.optional(),
note: optionalNote.or(z.null()),
})
// A single edit never carries both a numeric and a qualitative value.
.refine((d) => d.value === undefined || d.valueText === undefined, {
message: "Provide value (numeric) or valueText (qualitative), not both",
path: ["value"],
})
.refine(
(d) =>
d.referenceLow === undefined ||
d.referenceLow === null ||
d.referenceHigh === undefined ||
d.referenceHigh === null ||
d.referenceLow <= d.referenceHigh,
{
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
);
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).
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
from: z
.string()
.datetime()
.transform((s) => new Date(s))
.optional(),
to: z
.string()
.datetime()
.transform((s) => new Date(s))
.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
sortDir: z.enum(["asc", "desc"]).default("desc"),
});
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
/**
* Reference-range classification lives in `@/lib/labs/reference-range` so the
* API response, the doctor-report PDF, and the lab list card all share one
* implementation. Re-exported here for the existing import sites.
*/
export {
classifyReferenceRange,
formatReferenceRange,
type ReferenceRangeStatus,
} from "@/lib/labs/reference-range";