mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The structured lab-create path (POST /api/labs) now accepts an optional
`source` marker ("MANUAL" | "OCR"). The on-device-OCR ingestion path posts
"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", so the existing hand-entry contract is
unchanged. The value is narrowed by a closed Zod enum and assigned
field-by-field, never spread from the body. Mirrors the "OCR" source the
server-side /api/labs/ocr/commit path already stamps.
Regenerate the OpenAPI spec for the widened request body.
253 lines
9.0 KiB
TypeScript
253 lines
9.0 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();
|
|
|
|
/**
|
|
* 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,
|
|
takenAt: takenAtField,
|
|
note: optionalNote,
|
|
source,
|
|
})
|
|
// 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";
|