Files
temetro/backend/src/lib/patient-validation.ts
T
Khalid Abdi 2c9cec0112 backend: tolerant patient import validation
Real-world exports were rejected wholesale (file number must be digits,
sex must be M/F, every allergy/med/problem/encounter a full object), so
AI imports skipped everything. Normalize at the schema edge instead:
strip non-digits from the file number, map gender words to M/F, accept a
bare string for allergies/medications/problems (filling sensible
defaults), and default missing encounter fields (type → "Visit"). The
manual patient form shares this schema and benefits too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 20:52:33 +03:00

125 lines
4.2 KiB
TypeScript

import { z } from "zod";
import { initialsFromName } from "./initials.js";
const nonEmpty = z.string().trim().min(1);
const EMPTY_VITALS = { bp: "", hr: "", temp: "", spo2: "", takenAt: "" };
const EMPTY_TREND = { label: "", unit: "", points: [] as number[] };
// Coerce a bare string into an object keyed on its primary field, so a sparse
// export ("Penicillin", "Metformin") still validates — both the AI import and
// the patient form benefit. Real objects pass through untouched.
const stringToObject = (key: string) => (value: unknown) =>
typeof value === "string" ? { [key]: value } : value;
export const allergySchema = z.preprocess(
stringToObject("substance"),
z.object({
substance: nonEmpty,
reaction: z.string().default(""),
severity: z.enum(["mild", "moderate", "severe"]).default("mild"),
}),
);
export const medicationSchema = z.preprocess(
stringToObject("name"),
z.object({
name: nonEmpty,
dose: z.string().default(""),
frequency: z.string().default(""),
}),
);
export const problemSchema = z.preprocess(
stringToObject("label"),
z.object({
label: nonEmpty,
since: z.string().default(""),
}),
);
export const labSchema = z.object({
name: nonEmpty,
value: nonEmpty,
flag: z.enum(["normal", "high", "low", "critical"]).default("normal"),
takenAt: z.string().default(""),
});
export const encounterSchema = z.object({
date: z.string().default(""),
// A visit row always has a department/type, but never let it be empty.
type: z
.string()
.default("")
.transform((s) => s.trim() || "Visit"),
provider: z.string().default(""),
summary: z.string().default(""),
});
export const vitalsSchema = z.object({
bp: z.string(),
hr: z.string(),
temp: z.string(),
spo2: z.string(),
takenAt: z.string(),
});
export const trendSchema = z.object({
label: z.string(),
unit: z.string(),
points: z.array(z.number()),
});
// A full patient payload — the frontend form sends the entire record on both
// create and edit, so the same schema covers both.
//
// Tolerant by design: an AI import from a sparse export (e.g. just a name) still
// validates. The file number may be empty (the patient service auto-generates
// one), demographics fall back to safe placeholders, initials are derived from
// the name, and the clinical sections default to empty. Such rows are stamped
// `source: "ai"` and surfaced with an "Added by AI" badge for later editing.
export const patientInputSchema = z
.object({
// Real-world exports use IDs like "P00001"; keep only the digits (empty ⇒
// the patient service auto-generates one). Never reject on stray letters.
fileNumber: z.preprocess(
(v) => (typeof v === "string" ? v.replace(/\D/g, "") : v),
z.string().trim().default(""),
),
name: nonEmpty,
age: z.coerce.number().int().min(0).max(150).default(0),
// Accept gender words (Male / female / man / F / …), not just M/F.
sex: z.preprocess((v) => {
if (typeof v !== "string") return v;
const s = v.trim().toLowerCase();
if (s.startsWith("m")) return "M";
if (s.startsWith("f") || s.startsWith("w")) return "F";
return v;
}, z.enum(["M", "F"]).default("M")),
pcp: z.string().default(""),
// Optional link to the responsible clinician (user id). Empty string ⇒ null.
primaryProviderId: z.preprocess(
(v) => (v === "" ? null : v),
z.string().nullable().optional(),
),
status: z.enum(["active", "inpatient", "discharged"]).default("active"),
initials: z.string().trim().max(4).default(""),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
problems: z.array(problemSchema).default([]),
vitals: vitalsSchema.default(EMPTY_VITALS),
vitalsTrend: trendSchema.default(EMPTY_TREND),
labs: z.array(labSchema).default([]),
labTrend: trendSchema.default(EMPTY_TREND),
encounters: z.array(encounterSchema).default([]),
source: z.enum(["manual", "ai"]).default("manual"),
})
.transform((v) => ({
...v,
initials: v.initials || initialsFromName(v.name),
}));
export type PatientInput = z.infer<typeof patientInputSchema>;