Files
HealthLog/src/lib/validations/labs.ts
T
Marc-André Bombeck 0e9c5f1a00 Drop unimported re-exports outside the barrels
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.

Definitions are untouched; imports that existed only to feed a removed
re-export go too.
2026-08-23 19:11:12 +02:00

316 lines
11 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 { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
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();
/**
* The reference range the SOURCE REPORT printed for this one reading.
*
* Distinct from `referenceLow` / `referenceHigh` above, which seed (and are
* stamped from) the catalog marker's band. A lab runs its own method on its
* own device, so the window a physician evaluates against is the one on that
* report; these fields carry it, and `resolveEffectiveReferenceRange` lets it
* win for that reading alone.
*
* `sourceReferenceText` is what was printed, verbatim. It is accepted with or
* without derivable bounds: a window stated as "negativ" or "siehe Befund"
* still belongs on the record, and dropping it because no number could be read
* out of it is exactly the loss this field exists to stop.
*/
const sourceReferenceLow = z.number().finite().optional();
const sourceReferenceHigh = z.number().finite().optional();
const sourceReferenceText = z
.string()
.trim()
.max(SOURCE_REFERENCE_TEXT_MAX)
.optional()
.or(z.literal("").transform(() => undefined));
/**
* 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();
/**
* v1.25 (iOS #36) — optional provenance marker. The on-device-OCR ingestion
* path posts `source: "OCR"` so a reading captured from a phone-camera lab
* photo records its provenance; the raw image never touches the server on this
* path. Omitting the field reads as `"MANUAL"`, preserving the legacy
* hand-entry contract exactly. Mirrors the `source: "OCR"` the server-side
* `/api/labs/ocr/commit` path already stamps.
*/
const source = z.enum(["MANUAL", "OCR"]).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,
sourceReferenceLow,
sourceReferenceHigh,
sourceReferenceText,
takenAt: takenAtField,
note: optionalNote,
source,
/**
* The visit this reading came out of, when the form offered one. Optional
* always: a reading saves with no visit named, and the link never gates
* the write.
*/
encounterId: z.string().trim().min(1).max(40).optional(),
})
// 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"],
},
)
// Same guard on the source window. A transposed window is a client error
// here for the same reason: nothing downstream can tell it from a real one.
.refine(
(d) =>
d.valueText !== undefined ||
d.sourceReferenceLow === undefined ||
d.sourceReferenceHigh === undefined ||
d.sourceReferenceLow <= d.sourceReferenceHigh,
{
message: "sourceReferenceLow must not exceed sourceReferenceHigh",
path: ["sourceReferenceLow"],
},
);
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()),
sourceReferenceLow: sourceReferenceLow.or(z.null()),
sourceReferenceHigh: sourceReferenceHigh.or(z.null()),
sourceReferenceText: sourceReferenceText.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"],
},
)
.refine(
(d) =>
d.sourceReferenceLow === undefined ||
d.sourceReferenceLow === null ||
d.sourceReferenceHigh === undefined ||
d.sourceReferenceHigh === null ||
d.sourceReferenceLow <= d.sourceReferenceHigh,
{
message: "sourceReferenceLow must not exceed sourceReferenceHigh",
path: ["sourceReferenceLow"],
},
);
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,
type EffectiveReferenceRange,
type ReferenceRangeOrigin,
type ReferenceRangeStatus,
type SourceReferenceRange,
} from "@/lib/labs/reference-range";