mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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.
197 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|