/** * 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, parseDoctorReportPrefs, 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; /** * The top-level keys that exist ONLY in the grouped {@link exportSectionsSchema} * (never in the flat `doctorReportPrefsSchema`). Their presence is the reliable * signal that a persisted `sectionsJson` blob is the grouped shape rather than * the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top * level, the grouped shape nests them under `vitals`. */ const GROUPED_SECTION_KEYS = [ "vitals", "cardioFitness", "activity", "medications", "glucose", ] as const; /** * v1.28.17 — resolve a STORED `sectionsJson` blob (from a clinician share link * or any consumer that persists the export selection) to the flat * {@link DoctorReportPrefs} the aggregator + PDF renderer consume. * * The share-link create schema accepts the GROUPED {@link exportSectionsSchema} * and persists it raw, but the report aggregator speaks the FLAT shape. Reading * a grouped blob through the flat parser silently DROPS every grouped toggle * (`vitals.bp`, `activity.sleep`, `medications.compliance`) and falls back to * defaults-ON — so a section the owner switched OFF would still be served. This * resolver closes that gap: a grouped blob is folded through * {@link toDoctorReportPrefs}; a flat / empty / `{}` / null blob keeps the exact * legacy semantics via {@link parseDoctorReportPrefs}. Detection is by the * grouped-only keys, so it can never misread a flat blob as grouped (or vice * versa), and it needs no data migration — existing rows resolve correctly. */ export function resolveStoredReportSections(raw: unknown): DoctorReportPrefs { if (raw != null && typeof raw === "object" && !Array.isArray(raw)) { const obj = raw as Record; const looksGrouped = GROUPED_SECTION_KEYS.some((k) => k in obj); if (looksGrouped) { const parsed = exportSectionsSchema.safeParse(obj); // A well-formed grouped blob folds down; a drifted one falls through to // the flat parser, which itself defaults on any shape it cannot read. if (parsed.success) return toDoctorReportPrefs(parsed.data); } } return parseDoctorReportPrefs(raw); } /** * 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; /** * 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, }; }