mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
316 lines
11 KiB
TypeScript
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";
|