Files
HealthLog/src/lib/validations/health-record-export.ts
T
Marc-André Bombeck 9b4f3ce56d feat(records): surface structured allergies and family history in the doctor report and AI context
The structured Allergy / FamilyHistoryEntry stores reached FHIR export
only: the doctor-report PDF shipped without the two sections every
clinical intake asks for first, and the Coach knew allergies only from
the free-text answer — a penicillin allergy recorded in the structured
records UI never reached either surface.

Doctor report: two new section toggles (allergies, familyHistory; both
default ON, same stance as labs) across the prefs schema, the export
selection, the aggregator, the PDF renderer, and the export panel. The
tables render stored fields only — substance/category/kind/severity/
reaction/status and relationship/condition/age-at-onset; the reaction
decrypts fail-soft per row and free-text notes are never read, matching
the illness-journal stance. OpenAPI regenerated for the extended
sections shape.

Coach: the about-me context block now appends the structured rows as
clearly labelled entries (authoritative over the free-text answer on
conflict); the projection is a pure composer with unit tests. New
section labels land in all six locales.
2026-07-05 17:54:32 +02:00

151 lines
5.0 KiB
TypeScript

/**
* v1.7.0 — health-record / doctor-handover export selection.
*
* Single strict Zod schema driving `POST /api/export/health-record`. The
* flagship export route fails loudly on bad input (unlike the legacy
* doctor-report route which tolerates drift) — `.strict()` rejects unknown
* keys, and there is intentionally NO `userId` field (the user is always
* narrowed from `requireAuth()`, never accepted from the body).
*
* The grouped `sections` shape is additive over the flat
* `DoctorReportPrefs` used by the aggregator + PDF renderer. The
* `toDoctorReportPrefs()` helper folds the grouped toggles down to the
* flat shape the aggregator consumes, so PDF and FHIR describe identical
* numbers (the source-of-truth property the two PDF endpoints share).
*/
import { z } from "zod/v4";
import { locales } from "@/lib/i18n/config";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
/** Grouped vital toggles. */
const vitalsGroupSchema = z
.object({
weight: z.boolean(),
bp: z.boolean(),
pulse: z.boolean(),
oxygenSaturation: z.boolean(),
bodyFat: z.boolean(),
bodyComposition: z.boolean(),
})
.partial();
const cardioFitnessGroupSchema = z
.object({
restingHeartRate: z.boolean(),
hrv: z.boolean(),
vo2max: z.boolean(),
})
.partial();
const activityGroupSchema = z
.object({
steps: z.boolean(),
distance: z.boolean(),
energy: z.boolean(),
sleep: z.boolean(),
})
.partial();
const medicationsGroupSchema = z
.object({
list: z.boolean(),
compliance: z.boolean(),
glp1: z.boolean(),
sideEffects: z.boolean(),
})
.partial();
/**
* Grouped section toggles. Every group + key is optional so a partial
* payload (the user flipped one checkbox) doesn't have to re-state the
* whole tree. Missing keys resolve to the documented defaults.
*/
export const exportSectionsSchema = z
.object({
vitals: vitalsGroupSchema,
cardioFitness: cardioFitnessGroupSchema,
activity: activityGroupSchema,
glucose: z.boolean(),
medications: medicationsGroupSchema,
mood: z.boolean(),
bmi: z.boolean(),
// Cycle / reproductive health — opt-in (privacy default OFF, like mood).
cycle: z.boolean(),
// Structured lab results — ON by default (recorded to share with a
// clinician, same stance as BP / weight).
labs: z.boolean(),
// Structured allergy / intolerance records — ON by default (the
// section every clinical intake asks for first; same stance as labs).
allergies: z.boolean(),
// Structured family history — ON by default, same stance as allergies.
familyHistory: z.boolean(),
})
.partial();
export type ExportSections = z.infer<typeof exportSectionsSchema>;
/**
* Flagship export selection. `.strict()` so unknown keys (including any
* attempt to smuggle a `userId`) 422 via `returnAllZodIssues`.
*/
export const exportSelectionSchema = z
.object({
format: z.enum(["pdf", "fhir", "package"]),
range: z
.object({
startDate: z.iso.datetime({ offset: true }).optional(),
endDate: z.iso.datetime({ offset: true }).optional(),
days: z.number().int().min(1).max(365).optional(),
})
.strict()
.optional(),
sections: exportSectionsSchema.optional(),
locale: z.enum(locales).optional(),
practiceName: z.string().max(120).optional(),
includeCharts: z.boolean().optional(),
includeAiSummary: z.boolean().optional(),
// FHIR only: additionally emit the German BfArM ATC URI alongside the WHO
// entry on each medication concept (WHO stays first, byte-identical). When
// omitted, the route derives it from a German-region locale.
germanAtc: z.boolean().optional(),
})
.strict();
export type ExportSelection = z.infer<typeof exportSelectionSchema>;
/**
* Fold the grouped selection down to the flat `DoctorReportPrefs` the
* aggregator + PDF renderer consume. A group toggle that is `true` (or
* absent, falling back to the section default) flips the matching flat
* flag. Mood stays privacy-default-OFF: it only flips on when the caller
* explicitly set `sections.mood = true`.
*/
export function toDoctorReportPrefs(
sections: ExportSections | undefined,
): DoctorReportPrefs {
const s = sections ?? {};
const v = s.vitals ?? {};
const a = s.activity ?? {};
const m = s.medications ?? {};
const fallback = DEFAULT_DOCTOR_REPORT_PREFS;
return {
weight: v.weight ?? fallback.weight,
bp: v.bp ?? fallback.bp,
pulse: v.pulse ?? fallback.pulse,
bmi: s.bmi ?? fallback.bmi,
// Mood is opt-in: only true when explicitly requested.
mood: s.mood === true,
compliance: m.compliance ?? m.list ?? fallback.compliance,
sleep: a.sleep ?? fallback.sleep,
// Cycle is opt-in: only true when explicitly requested (privacy).
cycle: s.cycle === true,
labs: s.labs ?? fallback.labs,
allergies: s.allergies ?? fallback.allergies,
familyHistory: s.familyHistory ?? fallback.familyHistory,
};
}