Files
HealthLog/src/lib/validations/labs.ts
T
Marc-André Bombeck 73c4dda224 fix(visits): reach every lab result, condition, document and visit from the link pickers
The visit form's lab picker offered the newest 200 results, the condition
picker the newest 100 conditions, the document pickers the newest thousand
documents and the document sheet's visit picker the newest 100 visits.
On a long record everything older could not be linked.

Each picker now opens on the records around the date of the thing being
linked, pages older and newer from there, and searches through the list
route itself. Records already linked always show, from the labels the
record's own read carries and from what was picked in the sheet.

The lab list takes a q filter over analyte and panel, the condition list
from/to/q/offset/sortDir, and the visit list an offset.
2026-10-15 11:24:40 +02:00

321 lines
12 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(),
/**
* Case-insensitive substring over the analyte and the panel name, so a
* picker can find one result in a long history without paging to it.
*/
q: 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";