mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Three CONFIRMED findings from the campaign's feature-wiring audit + structural verification: - Sleep rendered nothing in the doctor-report PDF: the sleep section is default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed nothing (FHIR already emitted it). Added it to the allowlist with a minutes-to-hours conversion; corrected the stale 'intentionally excluded' comment. - Glucose had no doctor-report section toggle — it rendered whenever the global glucose module was on, with no report-level privacy control. The toggle actually already existed in the UI (with i18n) but was dead because the prefs fold dropped the key. Wired it end to end (glucose added to the prefs Zod schema + defaults, default-on to preserve behaviour; gated the render loop + the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as grouped and defaulted them all on. No migration (prefs is a JSON blob). - environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/ skin_temp to the snapshot. Added, scoped exactly to the environment cluster's five sources. Regression test per fix.
204 lines
7.5 KiB
TypeScript
204 lines
7.5 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`.
|
|
*
|
|
* `glucose` is deliberately NOT a discriminator: it is a top-level boolean in
|
|
* BOTH shapes (the flat `DoctorReportPrefs` gained its own `glucose` section
|
|
* toggle), so its presence no longer distinguishes grouped from flat. A real
|
|
* grouped export always carries one of the nested groups below, so detection
|
|
* stays reliable without it — and a bare `{ glucose: false }` blob correctly
|
|
* resolves through the flat parser.
|
|
*/
|
|
const GROUPED_SECTION_KEYS = [
|
|
"vitals",
|
|
"cardioFitness",
|
|
"activity",
|
|
"medications",
|
|
] 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,
|
|
glucose: s.glucose ?? fallback.glucose,
|
|
// 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,
|
|
};
|
|
}
|