Files
HealthLog/src/lib/validations/health-record-export.ts
T
Marc-André Bombeck 9b0d5dc0b7 chore(release): v1.28.17 — dashboard edits stick; share scope honoured
Dashboard layout changes take effect immediately: the save now evicts the
locale-keyed home-screen snapshot it previously missed, so a re-ordered or
toggled tile set no longer reverts to the prior selection for the SWR window.

A share link built with a narrowed report-section selection now serves exactly
that selection. The stored selection is the grouped export shape; the shared
view read it through the flat parser, which dropped every grouped toggle and
fell back to the full defaults — an over-share against the owner's choice. The
view now folds the grouped shape down faithfully. Documents-only shares were
never affected.

Hardening: the shared-document download path now sanitises the filename
identically to the owner download path, and thumbnail generation re-checks the
pixel ceiling after decode so a malformed image that slips past the header
sniff still cannot exhaust the worker.
2026-07-10 12:36:35 +02:00

197 lines
7.1 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,
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<typeof exportSectionsSchema>;
/**
* 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<string, unknown>;
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<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,
};
}