mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
242 lines
8.5 KiB
TypeScript
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";
|