From 68b9903966f2a254cebe65509f1e60309e81995a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 17:22:33 +0200 Subject: [PATCH 01/17] fix(export): make the doctor-report selection controls tell the truth The export panel rendered twenty data switches and eight of them changed nothing. The grouped selection folded down to twelve flat flags and dropped oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2 max, steps and distance on the way, so a user who switched any of them off still exported the readings in the PDF, in the FHIR bundle, in the package, through a clinician share link and over MCP. Each one now resolves to a flag the aggregator reads, and the rows are excluded at the query rather than fetched and dropped. Three more controls were untruthful in their own way: - The medications switch documented itself as excluding all medication data and excluded the adherence figures only. The drug list and the per-dose administration ledger rode along regardless. Both follow the switch now; the rows are still read, because compliance and the GLP-1 block are computed from them, and they no longer leave the aggregator. - Allergies and family history were gated for the PDF and fetched unconditionally for the FHIR bundle, so a package could carry a compliant PDF and a violating bundle in the same zip. The reads are gated, and a deselected section is not read at all. - The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control. They rode the mental-health module alone, which has been default-on since v1.29.1, so depression and anxiety screeners reached FHIR bundles, share links and the MCP doctor-visit resource on accounts that never chose to send them. They get their own section key, defaulting off, with the module gate kept as the second lock, and the stale privacy-by-default comment that argued otherwise is corrected. The share link's FHIR promise is removed rather than implemented. The create form offered a toggle and a resource-type picker, the values were persisted and badged on the finished link, and the API description called the toggle a REST reachability switch, while no route has ever answered a share token with FHIR or any other machine-readable format. Serving one is a feature with real security surface and does not belong in a correctness fix. The toggle, the picker, the badge and both claims are gone; the create schema rejects the two keys instead of accepting and ignoring them; the columns stay, unread, so no data is destroyed to undo a promise. Two files are split along seams this change already touched. The measurement-type gating maps and the filter that reads them leave the aggregator for their own module, so the query gate and the output gate cannot disagree; the export panel's control shape and the selection it builds leave the component, so the selection contract can be extended without the rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1, side effects) are dropped: nothing rendered them and nothing read them. --- docs/api/openapi.yaml | 44 +---- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- prisma/schema.prisma | 15 +- src/app/api/export/health-record/route.ts | 27 ++- src/app/api/share-links/route.ts | 21 +-- .../settings/health-record-export-controls.ts | 102 ++++++++++ .../settings/health-record-export-panel.tsx | 91 ++------- .../settings/share-link-create-form.tsx | 97 +--------- src/components/settings/sharing-section.tsx | 5 - .../clinician-share/resolve-share-token.ts | 8 - src/lib/doctor-report-data.ts | 174 +++++------------ src/lib/doctor-report-selection.ts | 177 ++++++++++++++++++ src/lib/openapi/routes/coach.ts | 2 +- src/lib/openapi/routes/health-record.ts | 2 - src/lib/validations/clinician-share-link.ts | 38 ++-- src/lib/validations/doctor-report-prefs.ts | 68 +++++++ src/lib/validations/health-record-export.ts | 40 +++- 22 files changed, 509 insertions(+), 426 deletions(-) create mode 100644 src/components/settings/health-record-export-controls.ts create mode 100644 src/lib/doctor-report-selection.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9448a580..2889c47f5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -938,8 +938,6 @@ paths: type: boolean distance: type: boolean - energy: - type: boolean sleep: type: boolean glucose: @@ -951,10 +949,6 @@ paths: type: boolean compliance: type: boolean - glp1: - type: boolean - sideEffects: - type: boolean mood: type: boolean bmi: @@ -967,6 +961,8 @@ paths: type: boolean familyHistory: type: boolean + mentalHealthScreeners: + type: boolean locale: type: string enum: @@ -1077,8 +1073,6 @@ paths: type: boolean distance: type: boolean - energy: - type: boolean sleep: type: boolean glucose: @@ -1090,10 +1084,6 @@ paths: type: boolean compliance: type: boolean - glp1: - type: boolean - sideEffects: - type: boolean mood: type: boolean bmi: @@ -1106,18 +1096,8 @@ paths: type: boolean familyHistory: type: boolean - resourceTypes: - maxItems: 8 - type: array - items: - type: string - enum: - - Patient - - Observation - - MedicationStatement - - MedicationAdministration - allowFhirApi: - type: boolean + mentalHealthScreeners: + type: boolean documentIds: maxItems: 50 type: array @@ -15735,12 +15715,6 @@ components: anyOf: - type: string - type: "null" - resourceTypes: - type: array - items: - type: string - allowFhirApi: - type: boolean protected: type: boolean documentCount: @@ -15779,8 +15753,6 @@ components: - label - rangeStart - rangeEnd - - resourceTypes - - allowFhirApi - protected - documentCount - expiresAt @@ -15827,12 +15799,6 @@ components: anyOf: - type: string - type: "null" - resourceTypes: - type: array - items: - type: string - allowFhirApi: - type: boolean protected: type: boolean documentCount: @@ -15858,8 +15824,6 @@ components: - label - rangeStart - rangeEnd - - resourceTypes - - allowFhirApi - protected - documentCount - expiresAt diff --git a/messages/de.json b/messages/de.json index 0457825b4..7e30c8472 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5331,6 +5331,7 @@ "glucose": "Blutzucker", "medications": "Medikamente & Therapietreue", "mood": "Stimmung (standardmäßig aus — sensible Daten)", + "mentalHealthScreeners": "Psychische Screenings (PHQ-9, GAD-7, WHO-5, SCI — standardmäßig aus)", "bmi": "BMI", "includeCharts": "Trend-Diagramme einbetten (nur PDF)", "includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)", @@ -5354,9 +5355,6 @@ "expiry": "Läuft ab in (Tagen)", "expiryHint": "Höchstens {max} Tage.", "expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.", - "fhirApi": "Eingegrenzte FHIR-API erlauben", - "fhirApiHint": "Lasse den Link zusätzlich zur Seitenansicht einen schreibgeschützten FHIR-Endpunkt bereitstellen.", - "resourceTypes": "FHIR-Ressourcentypen", "create": "Link erstellen", "tokenCreated": "Freigabe-Link erstellt", "copied": "In die Zwischenablage kopiert.", diff --git a/messages/en.json b/messages/en.json index 37e2e2186..4adcf54d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5321,6 +5321,7 @@ "glucose": "Blood glucose", "medications": "Medications & adherence", "mood": "Mood (off by default — sensitive data)", + "mentalHealthScreeners": "Mental-health screening (PHQ-9, GAD-7, WHO-5, SCI — off by default)", "bmi": "BMI", "includeCharts": "Embed trend charts (PDF only)", "includeAiSummary": "Append AI summary (not clinically validated)", @@ -5344,9 +5345,6 @@ "expiry": "Expires in (days)", "expiryHint": "At most {max} days.", "expiryInvalid": "Expiry must be between 1 and {max} days.", - "fhirApi": "Allow scoped FHIR API", - "fhirApiHint": "Let the link serve a read-only FHIR endpoint in addition to the page view.", - "resourceTypes": "FHIR resource types", "create": "Create link", "tokenCreated": "Share link created", "copied": "Copied to clipboard.", diff --git a/messages/es.json b/messages/es.json index 5430a002a..5b39c06ac 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5333,6 +5333,7 @@ "glucose": "Glucosa en sangre", "medications": "Medicamentos y adherencia", "mood": "Estado de ánimo (desactivado por defecto — datos sensibles)", + "mentalHealthScreeners": "Cribado de salud mental (PHQ-9, GAD-7, WHO-5, SCI — desactivado por defecto)", "bmi": "IMC", "includeCharts": "Incrustar gráficos de tendencia (solo PDF)", "includeAiSummary": "Añadir resumen de IA (no validado clínicamente)", @@ -5356,9 +5357,6 @@ "expiry": "Caduca en (días)", "expiryHint": "Como máximo {max} días.", "expiryInvalid": "La caducidad debe estar entre 1 y {max} días.", - "fhirApi": "Permitir API FHIR acotada", - "fhirApiHint": "Deja que el enlace sirva un endpoint FHIR de solo lectura además de la vista de página.", - "resourceTypes": "Tipos de recurso FHIR", "create": "Crear enlace", "tokenCreated": "Enlace para compartir creado", "copied": "Copiado al portapapeles.", diff --git a/messages/fr.json b/messages/fr.json index 0bc0a7a47..c44e23a39 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5335,6 +5335,7 @@ "glucose": "Glycémie", "medications": "Médicaments & observance", "mood": "Humeur (désactivé par défaut — données sensibles)", + "mentalHealthScreeners": "Dépistage de santé mentale (PHQ-9, GAD-7, WHO-5, SCI — désactivé par défaut)", "bmi": "IMC", "includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)", "includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)", @@ -5358,9 +5359,6 @@ "expiry": "Expire dans (jours)", "expiryHint": "Au plus {max} jours.", "expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.", - "fhirApi": "Autoriser l'API FHIR restreinte", - "fhirApiHint": "Laissez le lien servir un point d'accès FHIR en lecture seule en plus de la vue page.", - "resourceTypes": "Types de ressource FHIR", "create": "Créer le lien", "tokenCreated": "Lien de partage créé", "copied": "Copié dans le presse-papiers.", diff --git a/messages/it.json b/messages/it.json index 8064f1679..c562f129e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5325,6 +5325,7 @@ "glucose": "Glicemia", "medications": "Farmaci e aderenza", "mood": "Umore (disattivato di default — dati sensibili)", + "mentalHealthScreeners": "Screening di salute mentale (PHQ-9, GAD-7, WHO-5, SCI — disattivato di default)", "bmi": "BMI", "includeCharts": "Incorpora grafici di tendenza (solo PDF)", "includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)", @@ -5348,9 +5349,6 @@ "expiry": "Scade tra (giorni)", "expiryHint": "Al massimo {max} giorni.", "expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.", - "fhirApi": "Consenti API FHIR limitata", - "fhirApiHint": "Lascia che il link offra un endpoint FHIR di sola lettura oltre alla vista della pagina.", - "resourceTypes": "Tipi di risorsa FHIR", "create": "Crea link", "tokenCreated": "Link di condivisione creato", "copied": "Copiato negli appunti.", diff --git a/messages/pl.json b/messages/pl.json index 9d18cea87..48821edd1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5319,6 +5319,7 @@ "glucose": "Glukoza we krwi", "medications": "Leki i przestrzeganie zaleceń", "mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)", + "mentalHealthScreeners": "Przesiewowe testy zdrowia psychicznego (PHQ-9, GAD-7, WHO-5, SCI — domyślnie wyłączone)", "bmi": "BMI", "includeCharts": "Osadź wykresy trendów (tylko PDF)", "includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)", @@ -5342,9 +5343,6 @@ "expiry": "Wygasa za (dni)", "expiryHint": "Najwyżej {max} dni.", "expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.", - "fhirApi": "Zezwól na ograniczone API FHIR", - "fhirApiHint": "Pozwól, aby link udostępniał punkt końcowy FHIR tylko do odczytu obok widoku strony.", - "resourceTypes": "Typy zasobów FHIR", "create": "Utwórz link", "tokenCreated": "Link udostępniania utworzony", "copied": "Skopiowano do schowka.", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b31a9a02..2aad4be6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5350,8 +5350,8 @@ model PushAttempt { // `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is // returned to the owner exactly once at creation and never persisted. // -// The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and -// `resourceTypes` are write-once and a share can NEVER widen later — only +// The scope is FROZEN at creation: `rangeStart` and `sectionsJson` are +// write-once and a share can NEVER widen later — only // `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This // mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is // mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no @@ -5380,10 +5380,15 @@ model ClinicianShareLink { rangeEnd DateTime? @map("range_end") /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON. sectionsJson Json @map("sections_json") - /// FHIR resource types this link may serve (a subset of the catalogue), - /// e.g. `["Observation","MedicationStatement"]`. + /// RETIRED. These two columns described a scoped FHIR face for a + /// share link that was never built: no route ever served FHIR (or any + /// machine-readable format) against an `hls_` token, so the create form's + /// toggle, its resource-type picker, the list badge and the API description + /// all promised reachability that did not exist. The controls are gone, the + /// create path no longer writes anything but the defaults, and no read path + /// touches either column — the values still on rows minted earlier are inert. + /// Kept as columns so no data is destroyed to undo a promise. resourceTypes String[] @map("resource_types") - /// Whether the FHIR API is reachable via this link at all (vs the view only). allowFhirApi Boolean @default(false) @map("allow_fhir_api") /// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at /// create. When true the public view serves ONLY the attached documents and diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index adde3d743..de9f67fe2 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -150,14 +150,25 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.25 (W-RECORDS) — structured records are always-available reference // data (not time-windowed), so they ride alongside the report rather // than through `collectDoctorReportData`. Owner-scoped, live rows only. - prisma.allergy.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, - }), - prisma.familyHistoryEntry.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, - }), + // + // Gated on the section toggles. These two reads fed ONLY the + // FHIR bundle, and they ran unconditionally: `sections.allergies = false` + // produced a PDF without an allergy section and a bundle carrying every + // AllergyIntolerance, and in the `package` format both shipped in the + // same zip. Zero DB read when off, matching how the aggregator treats + // mood — deselected data should not be loaded, not merely dropped later. + sections.allergies + ? prisma.allergy.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }) + : Promise.resolve([]), + sections.familyHistory + ? prisma.familyHistoryEntry.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }) + : Promise.resolve([]), ]); // FHIR records folded into every bundle format (fhir + package). diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index b92a22e33..8f8fb22f3 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -65,8 +65,6 @@ const shareLinkSummarySelect = { label: true, rangeStart: true, rangeEnd: true, - resourceTypes: true, - allowFhirApi: true, documentOnly: true, passphraseHash: true, expiresAt: true, @@ -83,8 +81,6 @@ function toSummary(row: { label: string; rangeStart: Date; rangeEnd: Date | null; - resourceTypes: string[]; - allowFhirApi: boolean; documentOnly: boolean; passphraseHash: string | null; expiresAt: Date; @@ -99,8 +95,6 @@ function toSummary(row: { label: row.label, rangeStart: row.rangeStart.toISOString(), rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null, - resourceTypes: row.resourceTypes, - allowFhirApi: row.allowFhirApi, // v1.28.16 — the frozen documents-only flag (owner-facing; the serve path // gates on the column, not on "are all sections off?"). documentOnly: row.documentOnly, @@ -141,17 +135,14 @@ export const POST = apiHandler(async (request: NextRequest) => { const input = parsed.data; // v1.28.13 — a documents-only share carries NO report scope. Force the scope - // columns to empty here (not just at the client) so a document link can never - // serve a single health metric, whatever the body tried to smuggle: no - // sections (an explicit all-OFF prefs blob, distinct from the `{}` that means - // "full record defaults"), no FHIR resource types, FHIR API off. "Share this - // document" means the document, not the whole record. + // column to empty here (not just at the client) so a document link can never + // serve a single health metric, whatever the body tried to smuggle: an + // explicit all-OFF prefs blob, distinct from the `{}` that means "full record + // defaults". "Share this document" means the document, not the whole record. const documentOnly = input.documentOnly === true; const sectionsJson = documentOnly ? EMPTY_DOCTOR_REPORT_PREFS : (input.sections ?? {}); - const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []); - const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false); annotate({ meta: { documentOnly } }); // Mint the 192-bit raw token, store only its HMAC hash. @@ -196,8 +187,6 @@ export const POST = apiHandler(async (request: NextRequest) => { rangeStart: new Date(input.rangeStart), rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null, sectionsJson: sectionsJson as Prisma.InputJsonValue, - resourceTypes, - allowFhirApi, documentOnly, expiresAt: new Date(input.expiresAt), documents: @@ -213,8 +202,6 @@ export const POST = apiHandler(async (request: NextRequest) => { ipAddress: getClientIp(request), details: { shareLinkId: created.id, - resourceTypes: created.resourceTypes, - allowFhirApi: created.allowFhirApi, documentCount: created._count.documents, expiresAt: created.expiresAt.toISOString(), }, diff --git a/src/components/settings/health-record-export-controls.ts b/src/components/settings/health-record-export-controls.ts new file mode 100644 index 000000000..e3c8721ff --- /dev/null +++ b/src/components/settings/health-record-export-controls.ts @@ -0,0 +1,102 @@ +/** + * The data-selection controls of the health-record export panel: the state + * shape, its starting values, and the wire selection they build. + * + * Split out of the panel component so the selection contract is testable and + * extendable without the client component around it. The panel owns layout and + * interaction; this module owns "which controls exist and what each one sends", + * which is the part the export route, the aggregator and the control-gating + * guard all have to agree with — and the part the grouped-selection work will + * replace with a grouped registry, one file, without touching the rendering. + */ +/** + * Every data-selection control this panel renders, as one flat state shape. + * + * The shape is load-bearing beyond the component: the control-gating + * guard (`src/__tests__/doctor-report-control-gating-guard.test.ts`) walks these + * keys, folds each one through {@link buildSelectionSections} and + * `toDoctorReportPrefs`, and fails the build unless flipping it changes the + * resolved selection the aggregator consumes. Adding a control here without a + * gating path behind it is a build error, which is how eight controls that + * changed nothing stopped being possible. + */ +export interface SectionState { + weight: boolean; + bp: boolean; + pulse: boolean; + oxygenSaturation: boolean; + bodyFat: boolean; + bodyComposition: boolean; + restingHeartRate: boolean; + hrv: boolean; + vo2max: boolean; + steps: boolean; + distance: boolean; + sleep: boolean; + glucose: boolean; + medList: boolean; + compliance: boolean; + mood: boolean; + bmi: boolean; + labs: boolean; + allergies: boolean; + familyHistory: boolean; + mentalHealthScreeners: boolean; +} + +export const DEFAULT_SECTIONS: SectionState = { + weight: true, + bp: true, + pulse: true, + oxygenSaturation: true, + bodyFat: true, + bodyComposition: false, + restingHeartRate: false, + hrv: false, + vo2max: false, + steps: false, + distance: false, + sleep: false, + glucose: true, + medList: true, + compliance: true, + mood: false, // privacy default + bmi: true, + labs: true, + allergies: true, + familyHistory: true, + // Depression / anxiety / wellbeing / insomnia screening totals — opt-in, + // same stance as mood. + mentalHealthScreeners: false, +}; + +export function buildSelectionSections(s: SectionState) { + return { + vitals: { + weight: s.weight, + bp: s.bp, + pulse: s.pulse, + oxygenSaturation: s.oxygenSaturation, + bodyFat: s.bodyFat, + bodyComposition: s.bodyComposition, + }, + cardioFitness: { + restingHeartRate: s.restingHeartRate, + hrv: s.hrv, + vo2max: s.vo2max, + }, + activity: { + steps: s.steps, + distance: s.distance, + sleep: s.sleep, + }, + glucose: s.glucose, + medications: { list: s.medList, compliance: s.compliance }, + mood: s.mood, + bmi: s.bmi, + labs: s.labs, + allergies: s.allergies, + familyHistory: s.familyHistory, + mentalHealthScreeners: s.mentalHealthScreeners, + }; +} diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 70bc7d956..9b16222f1 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -44,87 +44,16 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; +import { + DEFAULT_SECTIONS, + buildSelectionSections, + type SectionState, +} from "@/components/settings/health-record-export-controls"; type ExportFormat = "pdf" | "fhir" | "package"; const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"]; -interface SectionState { - weight: boolean; - bp: boolean; - pulse: boolean; - oxygenSaturation: boolean; - bodyFat: boolean; - bodyComposition: boolean; - restingHeartRate: boolean; - hrv: boolean; - vo2max: boolean; - steps: boolean; - distance: boolean; - sleep: boolean; - glucose: boolean; - medList: boolean; - compliance: boolean; - mood: boolean; - bmi: boolean; - labs: boolean; - allergies: boolean; - familyHistory: boolean; -} - -const DEFAULT_SECTIONS: SectionState = { - weight: true, - bp: true, - pulse: true, - oxygenSaturation: true, - bodyFat: true, - bodyComposition: false, - restingHeartRate: false, - hrv: false, - vo2max: false, - steps: false, - distance: false, - sleep: false, - glucose: true, - medList: true, - compliance: true, - mood: false, // privacy default - bmi: true, - labs: true, - allergies: true, - familyHistory: true, -}; - -function buildSelectionSections(s: SectionState) { - return { - vitals: { - weight: s.weight, - bp: s.bp, - pulse: s.pulse, - oxygenSaturation: s.oxygenSaturation, - bodyFat: s.bodyFat, - bodyComposition: s.bodyComposition, - }, - cardioFitness: { - restingHeartRate: s.restingHeartRate, - hrv: s.hrv, - vo2max: s.vo2max, - }, - activity: { - steps: s.steps, - distance: s.distance, - sleep: s.sleep, - }, - glucose: s.glucose, - medications: { list: s.medList, compliance: s.compliance }, - mood: s.mood, - bmi: s.bmi, - labs: s.labs, - allergies: s.allergies, - familyHistory: s.familyHistory, - }; -} - export function HealthRecordExportPanel() { const { t, locale } = useTranslations(); const { user } = useAuth(); @@ -171,7 +100,10 @@ export function HealthRecordExportPanel() { // The single "Medikamente" control gates the whole medication block // (list + compliance) so unchecking it actually excludes all medication - // data from the export — matching the iOS five-section model. + // data from the export — matching the iOS five-section model. Until + // that was true of the compliance figures only until this change: the list + // names, doses and schedules, and the per-dose ledger behind it, went out + // regardless of the switch. function toggleMedications() { setSections((prev) => { const next = !(prev.medList && prev.compliance); @@ -451,6 +383,11 @@ export function HealthRecordExportPanel() { checked={sections.mood} onToggle={() => toggle("mood")} /> + toggle("mentalHealthScreeners")} + /> `, optionally - * exposing a scoped read-only FHIR face and a hand-picked, frozen-at-create - * document set. + * read-only view of the owner's own record at `/c/`, plus a hand-picked, + * frozen-at-create document set. It serves a rendered page and those documents + * and nothing else. A FHIR toggle and a resource-type picker were removed + * that configured an endpoint no route ever served. * * The raw `hls_` token, the passphrase, and the `#k=` QR are returned EXACTLY * ONCE on create (the server stores only hashes); this component is the single @@ -25,10 +26,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; import { ShareDocumentPicker, type PickedDocument, @@ -37,15 +36,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiPost } from "@/lib/api/api-fetch"; -/** The FHIR resource types a share link may serve — mirrors C4's enum. */ -const RESOURCE_TYPES = [ - "Patient", - "Observation", - "MedicationStatement", - "MedicationAdministration", -] as const; -type ResourceType = (typeof RESOURCE_TYPES)[number]; - /** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */ export const MAX_DAYS = 90; const DEFAULT_DAYS = 30; @@ -64,8 +54,6 @@ export interface ShareLinkSummary { label: string; rangeStart: string; rangeEnd: string | null; - resourceTypes: string[]; - allowFhirApi: boolean; /** v1.18.7 — whether a passphrase second factor guards this link. */ protected: boolean; /** v1.28 — size of the frozen document set (never the ids, never bytes). */ @@ -99,8 +87,6 @@ export interface ShareLinkCreatePayload { label: string; rangeStart: string; rangeEnd: null; - resourceTypes: ResourceType[]; - allowFhirApi: boolean; documentIds?: string[]; documentOnly?: boolean; expiresAt: string; @@ -111,8 +97,8 @@ export interface ShareLinkCreatePayload { * function so the two surfaces' scope contract is unit-testable without a DOM. * * The `documentOnly` branch is the privacy-load-bearing one: a document-launched - * share posts an EMPTY report scope (`resourceTypes: []`, no `sections`, FHIR - * off) plus `documentOnly: true`, and ALWAYS carries the picked document ids — + * share posts an EMPTY report scope (no `sections`) plus `documentOnly: true`, + * and ALWAYS carries the picked document ids — * so the created link serves the document(s) and nothing else. The record share * (Settings → Sharing) keeps its full scope and only attaches documents when * the owner picked some. @@ -121,8 +107,6 @@ export function buildShareLinkCreatePayload(input: { label: string; rangeDays: number; expiryDays: number; - allowFhirApi: boolean; - resourceTypes: ResourceType[]; documentIds: string[]; documentOnly: boolean; }): ShareLinkCreatePayload { @@ -135,8 +119,6 @@ export function buildShareLinkCreatePayload(input: { if (input.documentOnly) { return { ...base, - resourceTypes: [], - allowFhirApi: false, documentOnly: true, // The launched document(s) always ride along — a documents-only share // with no document would be meaningless (the server 422s it). @@ -145,8 +127,6 @@ export function buildShareLinkCreatePayload(input: { } return { ...base, - resourceTypes: input.resourceTypes, - allowFhirApi: input.allowFhirApi, // Omit the key entirely when nothing is attached (a documents-less record // share stays the default). The server re-validates each id as the caller's // own live document before minting the link. @@ -162,10 +142,9 @@ export function ShareLinkCreateForm({ }: { /** * Documents-only mode (the document-launched flow). Hides the record-scope - * controls — FHIR API toggle, FHIR resource-type checkboxes, and the - * history-range selector — and posts an empty report scope so the minted - * link serves ONLY the attached document(s). Keeps the document picker, - * label, expiry, and one-time passphrase reveal. + * control — the history-range selector — and posts an empty report scope so + * the minted link serves ONLY the attached document(s). Keeps the document + * picker, label, expiry, and one-time passphrase reveal. */ documentOnly?: boolean; /** Documents to pre-attach — the document-launched flow seeds the one doc. */ @@ -181,11 +160,6 @@ export function ShareLinkCreateForm({ const [label, setLabel] = useState(initialLabel ?? ""); const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS); const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS); - const [allowFhirApi, setAllowFhirApi] = useState(false); - const [resourceTypes, setResourceTypes] = useState([ - "Patient", - "Observation", - ]); const [selectedDocs, setSelectedDocs] = useState( initialDocuments ?? [], ); @@ -238,8 +212,6 @@ export function ShareLinkCreateForm({ label, rangeDays, expiryDays, - allowFhirApi, - resourceTypes, documentIds: selectedDocs.map((d) => d.id), documentOnly, }), @@ -268,12 +240,6 @@ export function ShareLinkCreateForm({ }, }); - function toggleResourceType(type: ResourceType) { - setResourceTypes((prev) => - prev.includes(type) ? prev.filter((r) => r !== type) : [...prev, type], - ); - } - async function copyValue(value: string, which: "link" | "passphrase") { try { await navigator.clipboard.writeText(value); @@ -344,51 +310,6 @@ export function ShareLinkCreateForm({ - {/* The FHIR API + its resource-type scope expose the health RECORD. - A documents-only link serves no record, so both are hidden and the - posted scope is forced empty server-side regardless. */} - {!documentOnly ? ( -
-
- -

- {t("settings.sharing.fhirApiHint")} -

-
- -
- ) : null} - - {!documentOnly && allowFhirApi && ( -
- - {t("settings.sharing.resourceTypes")} - -
- {RESOURCE_TYPES.map((type) => ( - - ))} -
-
- )} - {/* ── Attach documents ─────────────────────────────────────── */}
diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index e029b9f5f..fde3870bf 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -151,11 +151,6 @@ function ShareLinksCard() { {link.documentCount} )} - {link.allowFhirApi && ( - - FHIR - - )}

diff --git a/src/lib/clinician-share/resolve-share-token.ts b/src/lib/clinician-share/resolve-share-token.ts index 53ca7ff3a..d539ffd7e 100644 --- a/src/lib/clinician-share/resolve-share-token.ts +++ b/src/lib/clinician-share/resolve-share-token.ts @@ -48,10 +48,6 @@ export interface ShareContext { rangeEnd: Date | null; /** Frozen section toggles (the `DoctorReportPrefs` JSON shape). */ sectionsJson: unknown; - /** FHIR resource types this link may serve (a subset of the catalogue). */ - resourceTypes: string[]; - /** Whether the scoped FHIR API is reachable via this link at all. */ - allowFhirApi: boolean; /** * v1.28.16 — authoritative documents-only flag, frozen at create. When true * the view loader serves ONLY documents and never aggregates a health report, @@ -89,8 +85,6 @@ export async function resolveShareToken( rangeStart: true, rangeEnd: true, sectionsJson: true, - resourceTypes: true, - allowFhirApi: true, documentOnly: true, expiresAt: true, revokedAt: true, @@ -120,8 +114,6 @@ export async function resolveShareToken( rangeStart: row.rangeStart, rangeEnd: row.rangeEnd, sectionsJson: row.sectionsJson, - resourceTypes: row.resourceTypes, - allowFhirApi: row.allowFhirApi, documentOnly: row.documentOnly, expiresAt: row.expiresAt, }; diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index ec5bc55d7..f5085a054 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -41,7 +41,11 @@ import { type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; -import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { + excludedMeasurementTypes as excludedMeasurementTypesForSelection, + filterMeasurementKeys, +} from "@/lib/doctor-report-selection"; import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { WELLNESS_SCORE_REPORT_TYPES, @@ -448,38 +452,16 @@ export async function collectDoctorReportData( const recoveryEnabled = moduleMap.recovery !== false; const workoutsEnabled = moduleMap.workouts !== false; - // v1.28.25 — push the section / module gates INTO the measurement query. - // `filterMeasurementKeys` below strips gated types from the returned - // payload anyway, so rows of a disabled section / module were fetched only - // to be dropped — pure dead weight, and the heaviest series (BLOOD_GLUCOSE - // minute-grain CGM, gated by the glucose module) is exactly the one that - // runs to six figures over a 730-day window. Excluding them at the query - // is output-identical by construction. - // - // WEIGHT is deliberately NEVER excluded: the BMI figure (gated by - // `sections.bmi`, not `sections.weight`) and the GLP-1 weight-delta both - // read the PRE-filter `byType` / `stats` maps, so weight rows must be - // present even when the weight section itself is toggled off. No other - // gated type is read before the filter (each remaining pre-filter read — - // sleep nights, glucose panel, recovery summary — sits behind the same - // gate that drives its exclusion here). - // - // Every sparse type keeps the raw-row path. On long windows, the two - // sample-dense types move to one SQL row per local day/source/device/context; - // the canonical-source picker then works over that bounded intermediate set. - const excludedMeasurementTypes: MeasurementType[] = []; - if (sections.bp === false) { - excludedMeasurementTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"); - } - if (sections.pulse === false) excludedMeasurementTypes.push("PULSE"); - if (sections.sleep === false) excludedMeasurementTypes.push("SLEEP_DURATION"); - if (sections.glucose === false) - excludedMeasurementTypes.push("BLOOD_GLUCOSE"); - for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) { - if (moduleMap[moduleKey] === false) { - excludedMeasurementTypes.push(type as MeasurementType); - } - } + // v1.28.25 — the section / module gates are pushed INTO the measurement + // query (see `doctor-report-selection.ts` for why, and for the one type the + // query must keep regardless). Every sparse type keeps the raw-row path; on + // long windows the two sample-dense types move to one SQL row per local + // day/source/device/context, and the canonical-source picker then works over + // that bounded intermediate set. + const excludedMeasurementTypes = excludedMeasurementTypesForSelection( + sections, + moduleMap, + ); const userProfile = await prisma.user.findUnique({ where: { id: userId }, @@ -1403,22 +1385,37 @@ export async function collectDoctorReportData( glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null), bmi: filteredBmi, compliance: filteredCompliance, - medications: medications.map((m) => ({ - name: m.name, - dose: m.dose, - // v1.9.0 — drug-classification codes for the coded FHIR concept. - atcCode: m.atcCode, - rxNormCode: m.rxNormCode, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - })), - })), - medicationAdministrations: cappedAdministrations, - medicationAdministrationsTruncation: medicationAdministrationsTruncated - ? { total: totalAdministrations, included: cappedAdministrations.length } - : null, + // The medication list and the per-dose administration ledger now + // ride `sections.medicationList`. Both were exported unconditionally while + // the panel's single medication control claimed in its own comment to + // exclude all medication data, so a user who switched medications off still + // handed over every drug name, dose, schedule, injection site and skipped + // dose in the FHIR bundle. The rows are still READ (compliance and the + // GLP-1 block are computed from them); they no longer leave the function. + medications: sections.medicationList + ? medications.map((m) => ({ + name: m.name, + dose: m.dose, + // v1.9.0 — drug-classification codes for the coded FHIR concept. + atcCode: m.atcCode, + rxNormCode: m.rxNormCode, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + })), + })) + : [], + medicationAdministrations: sections.medicationList + ? cappedAdministrations + : [], + medicationAdministrationsTruncation: + sections.medicationList && medicationAdministrationsTruncated + ? { + total: totalAdministrations, + included: cappedAdministrations.length, + } + : null, mood, glp1, wellnessScores, @@ -1429,82 +1426,3 @@ export async function collectDoctorReportData( familyHistory, }; } - -/** - * Per-measurement-type → section-toggle mapping. Keys not listed here - * (e.g., body fat, oxygen saturation) bypass the toggles — those sections - * have no per-report flag. Glucose DOES carry a `glucose` section toggle, - * but `BLOOD_GLUCOSE` is gated at query-exclusion time (mirroring sleep) and - * its panel collapses via `glucoseEnabled`, so it does not ride this map. - */ -const MEASUREMENT_TYPE_SECTION: Record = { - BLOOD_PRESSURE_SYS: "bp", - BLOOD_PRESSURE_DIA: "bp", - WEIGHT: "weight", - PULSE: "pulse", - SLEEP_DURATION: "sleep", -}; - -/** - * v1.18.0 — per-measurement-type → owning module mapping for the types that - * carry no `sections` toggle but DO belong to a toggleable module. When the - * module is disabled, the type is stripped from `measurements` + `stats` so the - * series never reaches the PDF tables or a FHIR Observation. - * - * - `workouts` owns the activity / movement series (steps, active energy, - * distance, flights, the walking-gait + stair metrics) and the - * training-load `STRAIN_SCORE`. - * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`. - * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is - * gated where it is assembled; this strips the raw rows too). - * - * Core clinical types (BP / weight / pulse / body-composition / vitals) carry - * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride - * the `sections` mechanism instead. - */ -const MEASUREMENT_TYPE_MODULE: Record = { - ACTIVITY_STEPS: "workouts", - ACTIVE_ENERGY_BURNED: "workouts", - FLIGHTS_CLIMBED: "workouts", - WALKING_RUNNING_DISTANCE: "workouts", - WALKING_SPEED: "workouts", - WALKING_ASYMMETRY: "workouts", - WALKING_STEP_LENGTH: "workouts", - WALKING_DOUBLE_SUPPORT: "workouts", - SIX_MINUTE_WALK_DISTANCE: "workouts", - STAIR_ASCENT_SPEED: "workouts", - STAIR_DESCENT_SPEED: "workouts", - STRAIN_SCORE: "workouts", - RECOVERY_SCORE: "recovery", - STRESS_SCORE: "recovery", - BLOOD_GLUCOSE: "glucose", - // v1.25.0 — the PHQ-9 / GAD-7 screener TOTALS ride the doctor-report / FHIR - // export only when the opt-in mental-health module is on (default OFF → - // excluded by default, privacy-by-default). Item-level answers are never a - // Measurement, so they can never leak through this path regardless. The total - // is the intended clinical artefact (total + band per clinical-instruments.md - // §4); gating it on the module keeps the export consistent with how mood / - // sleep / glucose are gated and avoids exporting a screener the account never - // opted into. - PHQ9_SCORE: "mentalHealth", - GAD7_SCORE: "mentalHealth", - // v1.27.9 — the WHO-5 / SCI totals ride the same module gate. - WHO5_SCORE: "mentalHealth", - SCI_SCORE: "mentalHealth", -}; - -function filterMeasurementKeys( - map: Record, - sections: DoctorReportPrefs, - moduleMap: Record, -): Record { - const out: Record = {}; - for (const [type, value] of Object.entries(map)) { - const sectionKey = MEASUREMENT_TYPE_SECTION[type]; - if (sectionKey && sections[sectionKey] === false) continue; - const moduleKey = MEASUREMENT_TYPE_MODULE[type]; - if (moduleKey && moduleMap[moduleKey] === false) continue; - out[type] = value; - } - return out; -} diff --git a/src/lib/doctor-report-selection.ts b/src/lib/doctor-report-selection.ts new file mode 100644 index 000000000..5a18a2d9c --- /dev/null +++ b/src/lib/doctor-report-selection.ts @@ -0,0 +1,177 @@ +/** + * Which measurement types a doctor-report selection admits. + * + * Extracted from `doctor-report-data.ts` alongside the gating fix that made + * the export panel's controls real. The aggregator is a long file that reads + * a dozen data domains; the question "does this selection admit this + * measurement type?" is a separate job with a separate shape, it is the one + * the export panel's controls resolve to, and it is the piece the grouped + * selection work will grow. It answers to two independent gates, and both + * must pass: + * + * - the per-report SECTION toggle the user chose for this artefact, and + * - the app-wide MODULE switch that decides whether the domain exists here + * at all. + * + * Both maps and the filter that reads them live together so a type can never + * be gated in the query and ungated in the output, or the reverse. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { ModuleKey } from "@/lib/modules/gate"; +import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; + +/** + * Per-measurement-type → section-toggle mapping. This is the one enforcement + * point for "the user deselected this data": `filterMeasurementKeys` strips a + * gated type from `measurements` + `stats`, and PDF, FHIR bundle, package, + * clinician share view and MCP all read those two maps — so one entry here + * gates every format at once. + * + * The map used to hold five types, which is why the export panel + * could render controls for SpO₂, body fat, body composition, resting heart + * rate, HRV, VO₂max, steps and distance and change nothing at all. Every + * control the panel renders now resolves to a section key that appears here (or + * to a structured section gated by name in the aggregator body), and the + * control-gating guard test fails the build if that stops being true. + * + * Glucose DOES carry a `glucose` section toggle, but `BLOOD_GLUCOSE` is gated + * at query-exclusion time (mirroring sleep) and its panel collapses via + * `glucoseEnabled`, so it does not ride this map. + */ +export const MEASUREMENT_TYPE_SECTION: Record = + { + BLOOD_PRESSURE_SYS: "bp", + BLOOD_PRESSURE_DIA: "bp", + WEIGHT: "weight", + PULSE: "pulse", + SLEEP_DURATION: "sleep", + OXYGEN_SATURATION: "oxygenSaturation", + BODY_FAT: "bodyFat", + // The scale-derived composition family: everything a body-composition scale + // reports beyond the plain weight and fat percentage. + TOTAL_BODY_WATER: "bodyComposition", + BONE_MASS: "bodyComposition", + FAT_MASS: "bodyComposition", + FAT_FREE_MASS: "bodyComposition", + MUSCLE_MASS: "bodyComposition", + LEAN_BODY_MASS: "bodyComposition", + VISCERAL_FAT: "bodyComposition", + RESTING_HEART_RATE: "restingHeartRate", + // Both HRV estimators ride one control: SDNN and RMSSD are the same signal to + // a reader deciding whether to hand over heart-rate variability at all. + HEART_RATE_VARIABILITY: "hrv", + HRV_RMSSD: "hrv", + VO2_MAX: "vo2max", + ACTIVITY_STEPS: "steps", + WALKING_RUNNING_DISTANCE: "distance", + // Screening totals. The `mentalHealth` module gate below still applies; this + // is the per-artefact choice that gate stopped standing in for. + PHQ9_SCORE: "mentalHealthScreeners", + GAD7_SCORE: "mentalHealthScreeners", + WHO5_SCORE: "mentalHealthScreeners", + SCI_SCORE: "mentalHealthScreeners", + }; + +/** + * v1.18.0 — per-measurement-type → owning module mapping for the types that + * carry no `sections` toggle but DO belong to a toggleable module. When the + * module is disabled, the type is stripped from `measurements` + `stats` so the + * series never reaches the PDF tables or a FHIR Observation. + * + * - `workouts` owns the activity / movement series (steps, active energy, + * distance, flights, the walking-gait + stair metrics) and the + * training-load `STRAIN_SCORE`. + * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`. + * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is + * gated where it is assembled; this strips the raw rows too). + * + * Core clinical types (BP / weight / pulse / body-composition / vitals) carry + * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride + * the `sections` mechanism instead. + */ +export const MEASUREMENT_TYPE_MODULE: Record = { + ACTIVITY_STEPS: "workouts", + ACTIVE_ENERGY_BURNED: "workouts", + FLIGHTS_CLIMBED: "workouts", + WALKING_RUNNING_DISTANCE: "workouts", + WALKING_SPEED: "workouts", + WALKING_ASYMMETRY: "workouts", + WALKING_STEP_LENGTH: "workouts", + WALKING_DOUBLE_SUPPORT: "workouts", + SIX_MINUTE_WALK_DISTANCE: "workouts", + STAIR_ASCENT_SPEED: "workouts", + STAIR_DESCENT_SPEED: "workouts", + STRAIN_SCORE: "workouts", + RECOVERY_SCORE: "recovery", + STRESS_SCORE: "recovery", + BLOOD_GLUCOSE: "glucose", + // The PHQ-9 / GAD-7 / WHO-5 / SCI screener TOTALS ride the mental-health + // module as well as the `mentalHealthScreeners` section key above. Item-level + // answers are never a Measurement, so they can never leak through this path + // regardless; the total is the intended clinical artefact (total + band per + // clinical-instruments.md §4). + // + // The module gate used to be the ONLY gate, on the argument that + // the module was opt-in and therefore default-OFF. It has been default-ON + // since v1.29.1, so that argument stopped holding and depression / anxiety + // screening totals reached FHIR bundles, the MCP doctor-visit resource and + // clinician share links on accounts that never chose to send them. The + // section key restores the choice; the module gate stays as the second lock. + PHQ9_SCORE: "mentalHealth", + GAD7_SCORE: "mentalHealth", + WHO5_SCORE: "mentalHealth", + SCI_SCORE: "mentalHealth", +}; + +export function filterMeasurementKeys( + map: Record, + sections: DoctorReportPrefs, + moduleMap: Record, +): Record { + const out: Record = {}; + for (const [type, value] of Object.entries(map)) { + const sectionKey = MEASUREMENT_TYPE_SECTION[type]; + if (sectionKey && sections[sectionKey] === false) continue; + const moduleKey = MEASUREMENT_TYPE_MODULE[type]; + if (moduleKey && moduleMap[moduleKey] === false) continue; + out[type] = value; + } + return out; +} + +/** + * The measurement types to exclude at QUERY time for a resolved selection. + * + * v1.28.25 pushed the gates into the SQL because rows of a deselected section + * were fetched only to be dropped by {@link filterMeasurementKeys} — pure dead + * weight, and the heaviest series (minute-grain CGM) is exactly the one that + * runs to six figures over a long window. This is output-identical by + * construction because it reads the same two maps the filter reads. + * + * WEIGHT is deliberately NEVER excluded: the BMI figure (gated by + * `sections.bmi`, not `sections.weight`) and the GLP-1 weight delta both read + * the PRE-filter maps, so weight rows must be present even when the weight + * section itself is off. No other gated type is read before the filter — each + * remaining pre-filter read (sleep nights, the glucose panel, the recovery + * summary) sits behind the same gate that drives its exclusion here. + */ +export function excludedMeasurementTypes( + sections: DoctorReportPrefs, + moduleMap: Record, +): MeasurementType[] { + const excluded: MeasurementType[] = []; + for (const [type, sectionKey] of Object.entries(MEASUREMENT_TYPE_SECTION)) { + if (type === WEIGHT_PREFILTER_EXCEPTION) continue; + if (sections[sectionKey] === false) excluded.push(type as MeasurementType); + } + // Glucose carries a section toggle but no entry in the type→section map: the + // raw series is gated here and the panel it feeds collapses separately. + if (sections.glucose === false) excluded.push("BLOOD_GLUCOSE"); + for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) { + if (moduleMap[moduleKey] === false) excluded.push(type as MeasurementType); + } + return excluded; +} + +/** The one type the query gate must keep even when its section is off. */ +const WEIGHT_PREFILTER_EXCEPTION = "WEIGHT"; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 4a3b04e38..4f8c24426 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -231,7 +231,7 @@ const insightsLayoutResult = insightsLayoutSchema createShareLinkSchema.meta({ id: "CreateShareLinkRequest", description: - "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. Strict: unknown keys 422.", + "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `sections` freezes which record domains the link may serve. A share link serves the rendered page and the documents frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys 422.", }); exportSelectionSchema.meta({ diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 57137b7b8..4e118066d 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -20,8 +20,6 @@ const shareLinkSummary = z.object({ label: z.string(), rangeStart: z.string(), rangeEnd: z.string().nullable(), - resourceTypes: z.array(z.string()), - allowFhirApi: z.boolean(), // v1.18.7 — whether a passphrase second factor guards this link. Always true // for links created on v1.18.7+; false only for legacy links. protected: z.boolean(), diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index fb98b488e..6e0428acf 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -4,33 +4,26 @@ * The OWNER creates a time-boxed, scope-frozen share link to their own health * record. On create the server mints an `hls_<48 hex>` token (192-bit), stores * ONLY its HMAC hash, and returns the raw token exactly once. Every scope - * column (window, sections, FHIR resource types, API toggle) is write-once at - * creation — there is no widen/update path. `expiresAt` is REQUIRED and capped - * at `SHARE_LINK_MAX_DAYS` so no link ever lives forever. + * column (window, sections) is write-once at creation — there is no + * widen/update path. `expiresAt` is REQUIRED and capped at + * `SHARE_LINK_MAX_DAYS` so no link ever lives forever. + * + * The `resourceTypes` / `allowFhirApi` keys are gone. A share link + * serves a rendered page and the documents frozen onto it; it has never served + * FHIR, and accepting a scope for a face that does not exist only made the + * promise look kept. An unknown key is rejected by `.strict()`, so a caller + * still sending them gets a 422 that names them rather than a silent no-op. * * Strict: `.strict()` rejects unknown keys; there is intentionally no `userId` * field (the owner is always narrowed from `requireAuth()`). */ import { z } from "zod/v4"; -import { FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest"; import { exportSectionsSchema } from "@/lib/validations/health-record-export"; /** Maximum lifetime of a share link, in days. No never-expiring share. */ export const SHARE_LINK_MAX_DAYS = 90; -/** - * The FHIR resource types a share link may be scoped to serve — exactly the - * read-only catalogue the REST face exposes. Derived from the single canonical - * `FHIR_REST_RESOURCE_TYPES` so a share can never be scoped to a type the REST - * face does not actually route. A create request may select any subset; an - * empty array means "no FHIR resources" (view-only, moot when `allowFhirApi` - * is off). - */ -export const SHARE_LINK_RESOURCE_TYPES = FHIR_REST_RESOURCE_TYPES; - -export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES); - const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000; /** @@ -56,20 +49,17 @@ export const createShareLinkSchema = z rangeStart: z.iso.datetime({ offset: true }), rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(), sections: exportSectionsSchema.optional(), - resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(), - allowFhirApi: z.boolean().optional(), documentIds: z .array(z.string().trim().min(1).max(64)) .max(SHARE_LINK_MAX_DOCUMENTS) .optional(), /** * v1.28.13 — a documents-only share. When true the server FORCES an empty - * report scope (no sections, no FHIR resource types, FHIR API off) and - * mints a link that serves ONLY the attached documents — never a health - * metric. "Share this document" means the document, not the whole record. - * A documents-only share MUST carry at least one `documentId` (enforced - * below). Any `sections` / `resourceTypes` / `allowFhirApi` sent alongside - * are ignored server-side so a document link can never widen into a record. + * report scope and mints a link that serves ONLY the attached documents — + * never a health metric. "Share this document" means the document, not the + * whole record. A documents-only share MUST carry at least one + * `documentId` (enforced below). Any `sections` sent alongside are ignored + * server-side so a document link can never widen into a record. */ documentOnly: z.boolean().optional(), expiresAt: z.iso diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index 2da3c2075..48d8c5859 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -29,6 +29,7 @@ export const doctorReportPrefsSchema = z pulse: z.boolean(), bmi: z.boolean(), mood: z.boolean(), + medicationList: z.boolean(), compliance: z.boolean(), sleep: z.boolean(), glucose: z.boolean(), @@ -36,6 +37,15 @@ export const doctorReportPrefsSchema = z labs: z.boolean(), allergies: z.boolean(), familyHistory: z.boolean(), + oxygenSaturation: z.boolean(), + bodyFat: z.boolean(), + bodyComposition: z.boolean(), + restingHeartRate: z.boolean(), + hrv: z.boolean(), + vo2max: z.boolean(), + steps: z.boolean(), + distance: z.boolean(), + mentalHealthScreeners: z.boolean(), }) .partial(); @@ -52,6 +62,12 @@ export interface DoctorReportPrefs { pulse: boolean; bmi: boolean; mood: boolean; + /** + * The active-medication list (names, doses, schedules) and the + * per-dose administration ledger. Previously exported unconditionally while + * the only medication control folded into {@link DoctorReportPrefs.compliance}. + */ + medicationList: boolean; compliance: boolean; sleep: boolean; glucose: boolean; @@ -59,6 +75,26 @@ export interface DoctorReportPrefs { labs: boolean; allergies: boolean; familyHistory: boolean; + /** + * The measurement groups the export panel has always rendered a + * control for but nothing gated. Each one now drives + * `MEASUREMENT_TYPE_SECTION` in the aggregator, so it reaches PDF, FHIR and + * the package alike. + */ + oxygenSaturation: boolean; + bodyFat: boolean; + bodyComposition: boolean; + restingHeartRate: boolean; + hrv: boolean; + vo2max: boolean; + steps: boolean; + distance: boolean; + /** + * PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. Depression, + * anxiety, wellbeing and insomnia instruments: opt-in per artefact like + * mood, never carried by an absent key. + */ + mentalHealthScreeners: boolean; } /** @@ -73,6 +109,10 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { pulse: true, bmi: true, mood: false, // privacy default per the maintainer + // The medication list has always ridden along; the flag exists so the panel's + // single "medications" control can actually withhold it, not to change what a + // caller that omits the key receives. + medicationList: true, compliance: true, sleep: true, // Glucose the user recorded to share with a clinician — ON by default, @@ -93,6 +133,24 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { allergies: true, // Structured family history — same stance as allergies. familyHistory: true, + // The measurement groups that gained a real gate here. They default ON + // because that is what an omitted key has always produced; the point of the + // flags is that a caller who switches one OFF is now obeyed. The web panel + // sends every one of them explicitly. + oxygenSaturation: true, + bodyFat: true, + bodyComposition: true, + restingHeartRate: true, + hrv: true, + vo2max: true, + steps: true, + distance: true, + // Mental-health screening totals are opt-in per artefact, same stance as + // mood: PHQ-9 and GAD-7 are depression and anxiety instruments and must not + // leave the instance because a caller omitted a key. The `mentalHealth` + // module gate still applies on top — it just no longer stands alone, having + // been default-ON since v1.29.1. + mentalHealthScreeners: false, }; /** @@ -108,6 +166,7 @@ export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { pulse: false, bmi: false, mood: false, + medicationList: false, compliance: false, sleep: false, glucose: false, @@ -115,6 +174,15 @@ export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { labs: false, allergies: false, familyHistory: false, + oxygenSaturation: false, + bodyFat: false, + bodyComposition: false, + restingHeartRate: false, + hrv: false, + vo2max: false, + steps: false, + distance: false, + mentalHealthScreeners: false, }; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 37538d4d2..310cd26c2 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -45,17 +45,21 @@ const activityGroupSchema = z .object({ steps: z.boolean(), distance: z.boolean(), - energy: z.boolean(), sleep: z.boolean(), }) .partial(); +/** + * `list` gates the medication list plus the per-dose administration + * ledger; `compliance` gates the adherence figures and the GLP-1 therapy block. + * The retired `glp1` / `sideEffects` / `activity.energy` keys were accepted and + * discarded: no surface ever rendered them and no code read them. An unknown + * key inside a group is stripped, so a caller still sending one is unaffected. + */ const medicationsGroupSchema = z .object({ list: z.boolean(), compliance: z.boolean(), - glp1: z.boolean(), - sideEffects: z.boolean(), }) .partial(); @@ -83,6 +87,9 @@ export const exportSectionsSchema = z allergies: z.boolean(), // Structured family history — ON by default, same stance as allergies. familyHistory: z.boolean(), + // PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. OFF unless the + // caller sets it explicitly (`=== true`), like mood and cycle. + mentalHealthScreeners: z.boolean(), }) .partial(); @@ -172,14 +179,23 @@ 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`. + * flag. Mood, cycle and the mental-health screeners stay privacy-default-OFF: + * they only flip on when the caller set them explicitly. + * + * The fold used to read twelve of the twenty keys the schema + * accepts and drop the rest on the floor, so a caller that switched off SpO₂, + * body fat, body composition, resting heart rate, HRV, VO₂max, steps or + * distance was silently overruled and the export panel rendered eight controls + * that changed nothing. Every accepted key now resolves to a flat flag, and + * `doctor-report-control-gating-guard.test.ts` fails the build if a control is + * ever added back without one. */ export function toDoctorReportPrefs( sections: ExportSections | undefined, ): DoctorReportPrefs { const s = sections ?? {}; const v = s.vitals ?? {}; + const c = s.cardioFitness ?? {}; const a = s.activity ?? {}; const m = s.medications ?? {}; @@ -191,6 +207,10 @@ export function toDoctorReportPrefs( bmi: s.bmi ?? fallback.bmi, // Mood is opt-in: only true when explicitly requested. mood: s.mood === true, + medicationList: m.list ?? fallback.medicationList, + // A caller that sends only `medications.list = false` means "no medication + // data" — the single panel control has always driven both keys together, so + // the list value keeps standing in for a missing compliance value. compliance: m.compliance ?? m.list ?? fallback.compliance, sleep: a.sleep ?? fallback.sleep, glucose: s.glucose ?? fallback.glucose, @@ -199,5 +219,15 @@ export function toDoctorReportPrefs( labs: s.labs ?? fallback.labs, allergies: s.allergies ?? fallback.allergies, familyHistory: s.familyHistory ?? fallback.familyHistory, + oxygenSaturation: v.oxygenSaturation ?? fallback.oxygenSaturation, + bodyFat: v.bodyFat ?? fallback.bodyFat, + bodyComposition: v.bodyComposition ?? fallback.bodyComposition, + restingHeartRate: c.restingHeartRate ?? fallback.restingHeartRate, + hrv: c.hrv ?? fallback.hrv, + vo2max: c.vo2max ?? fallback.vo2max, + steps: a.steps ?? fallback.steps, + distance: a.distance ?? fallback.distance, + // Screening totals are opt-in: only true when explicitly requested. + mentalHealthScreeners: s.mentalHealthScreeners === true, }; } From f90219784b2a16cb0cea69dbe583cff75426e2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 17:22:48 +0200 Subject: [PATCH 02/17] test(export): pin the selection controls against going inert again Two layers, because the defect had two halves and each needs its own kind of proof. The structural guard walks the panel's control shape, folds every control through the wire schema and the resolver the route uses, and fails when flipping one changes nothing in the resolved selection. It also checks the panel renders a row for every control it declares, and that every resolved flag is read downstream, either by gating measurement types or by name in the aggregator. That is the link nobody had written: the dead switches were dead for several releases and no test connected "the panel renders this" to "the export honours this". The behavioural half runs the real export route and reads what comes out. The previously-dead measurement switches are asserted against the FHIR bundle and the PDF table, with a positive control beside each exclusion so nothing passes on an empty fixture. The screening totals are asserted absent for a caller that names other sections and for a caller that sends no sections at all, then present when asked for by name. Allergies and family history are asserted absent from the bundle and never read from the database, and the package case opens the zip and checks the bundle inside it agrees with the PDF beside it. Mutation-checked: reverting the allergy gate, the medication-list gate, the screener entries or the measurement entries turns the matching cases red and leaves the rest green; removing a control's fold entry or its aggregator entry turns the guard red with the control named. The remaining changes update fixtures the new shapes touch: prefs objects now resolve the added flags from the defaults, and the share-link fixtures drop the retired FHIR scope, with a case pinning that the create route now refuses those keys instead of ignoring them. --- ...doctor-report-control-gating-guard.test.ts | 187 +++++++++++ .../__tests__/route.test.ts | 16 +- .../health-record/__tests__/route.test.ts | 294 +++++++++++++++++- .../api/share-links/__tests__/route.test.ts | 42 ++- src/app/c/[token]/__tests__/page.test.tsx | 2 - .../c/[token]/d/[id]/__tests__/route.test.ts | 2 - .../__tests__/share-link-create-form.test.tsx | 22 +- .../__tests__/sharing-section.test.tsx | 9 +- .../__tests__/resolve-share-token.test.ts | 6 +- .../__tests__/share-view-data.test.ts | 2 - .../__tests__/doctor-report-prefs.test.ts | 9 +- 11 files changed, 533 insertions(+), 58 deletions(-) create mode 100644 src/__tests__/doctor-report-control-gating-guard.test.ts diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts new file mode 100644 index 000000000..b7c476dff --- /dev/null +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -0,0 +1,187 @@ +/** + * Structural guard: a rendered export control with no gating path fails the + * build. + * + * The health-record export panel shipped twenty data-selection switches, of + * which eight changed nothing at all. `toDoctorReportPrefs` folded the grouped + * wire shape down to twelve flat flags and dropped the rest on the floor, so a + * user who switched off SpO₂, body fat, body composition, resting heart rate, + * HRV, VO₂max, steps or distance exported them anyway — in the PDF, in the FHIR + * bundle, in the package, in a clinician share link and over MCP. Nothing + * failed; the control simply lied, and it lied for several releases because no + * test connected "the panel renders this" to "the export honours this". + * + * This file is that connection, in three links that each break independently: + * + * 1. Every control the panel holds state for survives the wire schema. A key + * the schema strips can never be honoured, however carefully the fold is + * written. + * 2. Flipping exactly one control changes the resolved `DoctorReportPrefs` + * the aggregator consumes. This is the assertion that would have caught + * all eight dead switches on the day they were added. + * 3. Every flag in the resolved shape is read downstream — either as a + * per-measurement-type entry in the selection gates or by name in the + * aggregator body. A flag nothing reads is the same defect one layer down. + * + * It also checks the panel actually renders every control it declares, so the + * split between the control shape and its rendering cannot quietly leave a + * declared control with no row (or a row with no gate). + * + * What it does NOT prove: that the excluded data is actually absent from a + * rendered artefact. That is a behavioural property and it is covered where the + * artefacts are built — `src/app/api/export/health-record/__tests__/route.test.ts` + * asserts the PDF, the FHIR bundle and the zip for the same selections. This + * guard is the cheap structural half that runs on every commit and cannot be + * satisfied by writing a control that looks plausible. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + exportSectionsSchema, + toDoctorReportPrefs, +} from "@/lib/validations/health-record-export"; +import { + DEFAULT_DOCTOR_REPORT_PREFS, + type DoctorReportPrefs, +} from "@/lib/validations/doctor-report-prefs"; +import { + DEFAULT_SECTIONS, + buildSelectionSections, + type SectionState, +} from "@/components/settings/health-record-export-controls"; +import { MEASUREMENT_TYPE_SECTION } from "@/lib/doctor-report-selection"; + +const REPO_ROOT = join(__dirname, "..", ".."); +const PANEL_PATH = join( + REPO_ROOT, + "src", + "components", + "settings", + "health-record-export-panel.tsx", +); +const AGGREGATOR_PATH = join(REPO_ROOT, "src", "lib", "doctor-report-data.ts"); + +const panelSource = readFileSync(PANEL_PATH, "utf8"); +const aggregatorSource = readFileSync(AGGREGATOR_PATH, "utf8"); + +/** Strip comments so a key mentioned only in prose does not count as read. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** + * The control set, taken from the panel's own starting values rather than from + * a list this test maintains — a list would drift exactly the way the fold + * drifted. `DEFAULT_SECTIONS` is typed `SectionState`, so TypeScript already + * forces it to name every control, and every rendered row must bind one of + * them. "Declared here" and "rendered" are therefore the same set. + */ +const CONTROL_KEYS = Object.keys(DEFAULT_SECTIONS) as (keyof SectionState)[]; + +/** All controls on, as the panel's state shape. */ +function allOn(): SectionState { + return Object.fromEntries( + CONTROL_KEYS.map((k) => [k, true]), + ) as unknown as SectionState; +} + +/** + * The exact path a selection takes in production: panel state → grouped wire + * shape → the route's Zod parse (which strips anything the schema does not + * declare) → the flat prefs the aggregator consumes. + */ +function resolve(state: SectionState): DoctorReportPrefs { + return toDoctorReportPrefs( + exportSectionsSchema.parse(buildSelectionSections(state)), + ); +} + +describe("export control gating — structural guard", () => { + it("reads a plausible control set", () => { + // Sanity floor: if the control shape silently degraded, every assertion + // below would pass vacuously. + expect(CONTROL_KEYS.length).toBeGreaterThanOrEqual(20); + expect(CONTROL_KEYS).toContain("bp"); + expect(CONTROL_KEYS).toContain("mentalHealthScreeners"); + }); + + it("renders a row for every control it declares", () => { + const unrendered = CONTROL_KEYS.filter( + (key) => !panelSource.includes(`sections.${key}`), + ); + expect( + unrendered, + `These controls are declared in the selection shape but the panel binds ` + + `no row to them, so the user has no way to reach them.`, + ).toEqual([]); + }); + + it("keeps every rendered control inside the wire schema", () => { + const wire = buildSelectionSections(allOn()); + // A round trip through the schema must not lose anything the panel sends. + expect(exportSectionsSchema.parse(wire)).toEqual(wire); + }); + + it("makes flipping any single control change the resolved selection", () => { + const baseline = resolve(allOn()); + const inert: string[] = []; + + for (const key of CONTROL_KEYS) { + const flipped = resolve({ ...allOn(), [key]: false }); + if (JSON.stringify(flipped) === JSON.stringify(baseline)) { + inert.push(key); + } + } + + expect( + inert, + `These panel controls are rendered but change nothing in the resolved ` + + `export selection. Give each one a flag in DoctorReportPrefs and a ` + + `gating path in doctor-report-data.ts, or remove the control.`, + ).toEqual([]); + }); + + it("has something downstream read every flag in the resolved selection", () => { + // A flag is honoured either by gating measurement types or by name in the + // aggregator body (the structured sections: labs, allergies, the + // medication block, the glucose panel). + const mappedFlags = new Set( + Object.values(MEASUREMENT_TYPE_SECTION), + ); + const body = stripComments(aggregatorSource); + + const unread = Object.keys(DEFAULT_DOCTOR_REPORT_PREFS).filter( + (key) => !mappedFlags.has(key) && !body.includes(`sections.${key}`), + ); + + expect( + unread, + `These resolved selection flags are never read downstream, so switching ` + + `them off would change nothing that leaves the instance.`, + ).toEqual([]); + }); + + it("keeps the sensitive selections off unless asked for by name", () => { + // Mood, cycle and the screening instruments must never ride an absent key: + // the mental-health module went default-ON in v1.29.1 and silently made + // PHQ-9 and GAD-7 totals part of a default export. + const fromNothing = toDoctorReportPrefs(undefined); + expect(fromNothing.mood).toBe(false); + expect(fromNothing.cycle).toBe(false); + expect(fromNothing.mentalHealthScreeners).toBe(false); + + // A partial selection that names other things must not flip them on. + const partial = toDoctorReportPrefs({ labs: true, vitals: { bp: true } }); + expect(partial.mood).toBe(false); + expect(partial.cycle).toBe(false); + expect(partial.mentalHealthScreeners).toBe(false); + + // Only an explicit `true` does. + expect( + toDoctorReportPrefs({ mentalHealthScreeners: true }) + .mentalHealthScreeners, + ).toBe(true); + }); +}); diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index b5c5a5584..d993076dc 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -99,10 +99,13 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); const env = (await res.json()) as { data: typeof body }; - expect(env.data).toEqual(body); + // The canonical shape is the full prefs object: keys the body omits fill + // from the defaults rather than being dropped. + const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body }; + expect(env.data).toEqual(canonical); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { doctorReportPrefsJson: body }, + data: { doctorReportPrefsJson: canonical }, }); }); @@ -134,10 +137,15 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { ); expect(res.status).toBe(200); const env = (await res.json()) as { data: typeof current }; - expect(env.data).toEqual({ ...current, mood: false }); + const merged = { + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...current, + mood: false, + }; + expect(env.data).toEqual(merged); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, - data: { doctorReportPrefsJson: { ...current, mood: false } }, + data: { doctorReportPrefsJson: merged }, }); }); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 2edf0ab3c..e5815ce7b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { PDFParse } from "pdf-parse"; +import { unzipSync } from "fflate"; vi.mock("@/lib/db", () => ({ prisma: { @@ -31,9 +32,15 @@ vi.mock("@/lib/auth/audit", () => ({ })); vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); -vi.mock("@/lib/tz/resolver", () => ({ - resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), -})); +// Partial: the aggregator's canonical-source collapse calls `userDayKey` from +// the same module, so a total mock breaks any fixture that carries rows. +vi.mock("@/lib/tz/resolver", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), + }; +}); // v1.18.0 — the health-record aggregator resolves the per-user module map // so a disabled data-domain module never reaches the export. Stub the gate @@ -250,6 +257,287 @@ describe("POST /api/export/health-record — outputs", () => { }); }); +/** + * The selection has to mean the same thing in every artefact. + * + * Three defects met here. The export panel rendered eight measurement switches + * that the grouped-to-flat fold discarded, so deselecting body fat or VO₂max + * exported them anyway. The medication switch documented itself as excluding + * all medication data while the drug list and the per-dose ledger went out + * unconditionally. And the route fetched allergies and family history without + * consulting the toggles at all, so `sections.allergies = false` produced a PDF + * without an allergy section and a FHIR bundle carrying every one of them — in + * the `package` format, inside the same zip. + * + * Each case below runs the real route end to end and reads the produced + * artefact. Mutation check: revert any single gate and its case goes red while + * the positive control beside it stays green, so none of them can pass by + * asserting on an empty fixture. + */ +describe("POST /api/export/health-record — the selection reaches every format", () => { + const T0 = new Date("2026-02-01T08:00:00.000Z"); + + function measurement(type: string, value: number) { + return { + type, + value, + measuredAt: T0, + source: "MANUAL", + deviceType: null, + sleepStage: null, + glucoseContext: null, + }; + } + + function allergyRow() { + return { + id: "allergy-1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: null, + reactionEncrypted: null, + notesEncrypted: null, + createdAt: T0, + updatedAt: T0, + }; + } + + function familyHistoryRow() { + return { + id: "fmh-1", + relationship: "MOTHER", + condition: "Hypertension", + ageAtOnset: 52, + notesEncrypted: null, + createdAt: T0, + updatedAt: T0, + }; + } + + function medicationRow() { + return { + id: "med-1", + name: "Sample medication", + dose: "5 mg", + atcCode: null, + rxNormCode: null, + deliveryForm: null, + active: true, + schedules: [], + scheduleRevisions: [], + pauseEras: [], + doseChanges: [], + intakeEvents: [], + }; + } + + /** Resource types present in the bundle a given selection produces. */ + async function bundleResourceTypes( + sections: Record, + ): Promise { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir", sections })); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string; code?: unknown } }[]; + }; + return bundle.entry.map((e) => e.resource.resourceType); + } + + /** The LOINC/HealthKit codes of every Observation a selection produces. */ + async function observationCodes( + sections: Record, + ): Promise { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir", sections })); + expect(res.status).toBe(200); + const bundle = (await res.json()) as { + entry: { + resource: { + resourceType: string; + code?: { coding?: { code: string }[]; text?: string }; + }; + }[]; + }; + return bundle.entry + .filter((e) => e.resource.resourceType === "Observation") + .flatMap((e) => [ + ...(e.resource.code?.coding?.map((c) => c.code) ?? []), + e.resource.code?.text ?? "", + ]); + } + + it("honours every previously-dead measurement switch in the FHIR bundle", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("BODY_FAT", 21), + measurement("OXYGEN_SATURATION", 97), + measurement("BONE_MASS", 3.1), + measurement("RESTING_HEART_RATE", 54), + measurement("HEART_RATE_VARIABILITY", 61), + measurement("VO2_MAX", 44), + measurement("ACTIVITY_STEPS", 8400), + measurement("WALKING_RUNNING_DISTANCE", 6200), + ] as never); + + // Positive control: with every switch on, all eight are emitted. Without + // this the exclusion assertion below could pass on an empty bundle. + const on = await observationCodes({ + vitals: { oxygenSaturation: true, bodyFat: true, bodyComposition: true }, + cardioFitness: { restingHeartRate: true, hrv: true, vo2max: true }, + activity: { steps: true, distance: true }, + }); + expect(on.length).toBeGreaterThanOrEqual(8); + + const off = await observationCodes({ + vitals: { + oxygenSaturation: false, + bodyFat: false, + bodyComposition: false, + }, + cardioFitness: { restingHeartRate: false, hrv: false, vo2max: false }, + activity: { steps: false, distance: false }, + }); + expect(off).toEqual([]); + }); + + it("keeps the deselected measurement rows out of the query in the first place", async () => { + await bundleResourceTypes({ + vitals: { bodyFat: false }, + cardioFitness: { vo2max: false }, + }); + const lastCall = vi.mocked(prisma.measurement.findMany).mock.calls.at(-1)!; + const where = lastCall[0]!.where as { type?: { notIn?: string[] } }; + expect(where.type?.notIn).toContain("BODY_FAT"); + expect(where.type?.notIn).toContain("VO2_MAX"); + }); + + it("omits the deselected vitals from the PDF table", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("BODY_FAT", 21), + measurement("OXYGEN_SATURATION", 97), + ] as never); + const { POST } = await import("../route"); + + const withBoth = await pdfText( + await POST( + mkReq({ + format: "pdf", + locale: "en", + sections: { vitals: { bodyFat: true, oxygenSaturation: true } }, + }), + ), + ); + expect(withBoth).toContain("Body fat"); + + const without = await pdfText( + await POST( + mkReq({ + format: "pdf", + locale: "en", + sections: { vitals: { bodyFat: false, oxygenSaturation: false } }, + }), + ), + ); + expect(without).not.toContain("Body fat"); + }); + + it("holds the mental-health screening totals back unless they are asked for by name", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + measurement("PHQ9_SCORE", 11), + measurement("GAD7_SCORE", 8), + ] as never); + + // The module is on (the default since v1.29.1) and the caller sends no + // screening key: the totals must stay in the database. + expect(await observationCodes({ labs: true })).toEqual([]); + // And an omitted `sections` object entirely — the MCP / share-link case. + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir" })); + const bundle = (await res.json()) as { + entry: { resource: { resourceType: string } }[]; + }; + expect( + bundle.entry.filter((e) => e.resource.resourceType === "Observation"), + ).toEqual([]); + + // Asked for explicitly, they are emitted — the data is withheld, not lost. + const asked = await observationCodes({ mentalHealthScreeners: true }); + expect(asked).toContain("44261-6"); + expect(asked).toContain("70274-6"); + }); + + it("lets the medication switch withhold the medication list", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medicationRow(), + ] as never); + + expect( + await bundleResourceTypes({ medications: { list: true } }), + ).toContain("MedicationStatement"); + expect( + await bundleResourceTypes({ medications: { list: false } }), + ).not.toContain("MedicationStatement"); + }); + + it("keeps allergies and family history out of the FHIR bundle when deselected", async () => { + vi.mocked(prisma.allergy.findMany).mockResolvedValue([ + allergyRow(), + ] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([ + familyHistoryRow(), + ] as never); + + const on = await bundleResourceTypes({ + allergies: true, + familyHistory: true, + }); + expect(on).toContain("AllergyIntolerance"); + expect(on).toContain("FamilyMemberHistory"); + + const off = await bundleResourceTypes({ + allergies: false, + familyHistory: false, + }); + expect(off).not.toContain("AllergyIntolerance"); + expect(off).not.toContain("FamilyMemberHistory"); + }); + + it("never reads the deselected records from the database", async () => { + await bundleResourceTypes({ allergies: false, familyHistory: false }); + expect(prisma.allergy.findMany).not.toHaveBeenCalled(); + expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled(); + }); + + it("ships a package whose bundle agrees with its PDF", async () => { + vi.mocked(prisma.allergy.findMany).mockResolvedValue([ + allergyRow(), + ] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([ + familyHistoryRow(), + ] as never); + + const { POST } = await import("../route"); + const res = await POST( + mkReq({ + format: "package", + sections: { allergies: false, familyHistory: false }, + }), + ); + expect(res.status).toBe(200); + const files = unzipSync(new Uint8Array(await res.arrayBuffer())); + const bundle = JSON.parse( + new TextDecoder().decode(files["bundle.json"]!), + ) as { entry: { resource: { resourceType: string } }[] }; + const types = bundle.entry.map((e) => e.resource.resourceType); + // The compliant PDF and the violating bundle used to travel together. + expect(types).not.toContain("AllergyIntolerance"); + expect(types).not.toContain("FamilyMemberHistory"); + }); +}); + /** * The practice name is a remembered preference (`User.lastReportPracticeName`), * so a monthly report does not ask for the clinic name again every time. The diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index e040c6b6e..57ae2e92b 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -64,8 +64,6 @@ function validBody(overrides: Record = {}) { label: "Cardiology clinic", rangeStart: "2026-01-01T00:00:00Z", rangeEnd: null, - resourceTypes: ["Observation"], - allowFhirApi: true, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), ...overrides, }; @@ -77,8 +75,6 @@ function storedRow(overrides: Record = {}) { label: "Cardiology clinic", rangeStart: new Date("2026-01-01T00:00:00Z"), rangeEnd: null, - resourceTypes: ["Observation"], - allowFhirApi: true, passphraseHash: "hash(STORED)", expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), createdAt: new Date(), @@ -241,22 +237,27 @@ describe("POST /api/share-links — create", () => { expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); }); + it("rejects the retired FHIR scope keys with 422", async () => { + // The share link has never served FHIR against its token. The keys + // are gone from the create schema rather than accepted and ignored, so a + // stale client is told plainly instead of believing it scoped something. + const res = await POST( + postReq( + validBody({ allowFhirApi: true, resourceTypes: ["Observation"] }), + ), + ); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + it("allows a documents-only share (empty report sections)", async () => { vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ { id: "doc-a" }, ] as never); vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( - storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, - ); - const res = await POST( - postReq( - validBody({ - resourceTypes: [], - allowFhirApi: false, - documentIds: ["doc-a"], - }), - ), + storedRow({ _count: { documents: 1 } }) as never, ); + const res = await POST(postReq(validBody({ documentIds: ["doc-a"] }))); expect(res.status).toBe(201); const body = (await res.json()) as { data: { documentCount: number } }; expect(body.data.documentCount).toBe(1); @@ -267,18 +268,15 @@ describe("POST /api/share-links — create", () => { { id: "doc-a" }, ] as never); vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( - storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never, + storedRow({ _count: { documents: 1 } }) as never, ); // A hostile / stale client tries to widen a document link into a record - // share: FHIR on, resource types set, sections requested. The server must - // ignore every one of them. + // share by requesting sections. The server must ignore them. const res = await POST( postReq( validBody({ documentOnly: true, - allowFhirApi: true, - resourceTypes: ["Observation", "Patient"], sections: { vitals: { bp: true }, labs: true }, documentIds: ["doc-a"], }), @@ -288,9 +286,9 @@ describe("POST /api/share-links — create", () => { const createArg = vi.mocked(prisma.clinicianShareLink.create).mock .calls[0][0]; - // Empty FHIR scope, FHIR API off — no health surface at all. - expect(createArg.data.resourceTypes).toEqual([]); - expect(createArg.data.allowFhirApi).toBe(false); + // The retired FHIR scope columns are never written at all. + expect(createArg.data).not.toHaveProperty("resourceTypes"); + expect(createArg.data).not.toHaveProperty("allowFhirApi"); // Sections persist as an explicit all-OFF blob (distinct from `{}` = full // record defaults). The clinician view reads this as "no report". expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS); diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 55b121b09..35416057d 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -94,8 +94,6 @@ function resolvedContext() { rangeStart: new Date(), rangeEnd: null, sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), }; } diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts index 96072c5c7..93adec25a 100644 --- a/src/app/c/[token]/d/[id]/__tests__/route.test.ts +++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts @@ -159,8 +159,6 @@ beforeEach(() => { rangeStart: new Date(), rangeEnd: null, sectionsJson: {}, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 3_600_000), } as never); }); diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx index 707c63ca1..96e17aef8 100644 --- a/src/components/settings/__tests__/share-link-create-form.test.tsx +++ b/src/components/settings/__tests__/share-link-create-form.test.tsx @@ -112,19 +112,17 @@ describe("buildShareLinkCreatePayload — scope contract", () => { expiryDays: 30, }; - it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => { + it("document-only: empty report scope and the launched doc always rides along", () => { const payload = buildShareLinkCreatePayload({ ...common, - // Even if the caller state still holds record-scope values, they must be - // dropped: a document link never carries a health scope. - allowFhirApi: true, - resourceTypes: ["Patient", "Observation"], documentIds: ["doc-1"], documentOnly: true, }); - expect(payload.resourceTypes).toEqual([]); - expect(payload.allowFhirApi).toBe(false); expect(payload.documentOnly).toBe(true); + // The retired FHIR scope keys are gone from the wire entirely, + // so a share link can no longer advertise a face that was never served. + expect(payload).not.toHaveProperty("resourceTypes"); + expect(payload).not.toHaveProperty("allowFhirApi"); // No report sections are ever sent for a document link. expect(payload).not.toHaveProperty("sections"); // The launched document is always in the created link (pre-attach fix). @@ -132,15 +130,15 @@ describe("buildShareLinkCreatePayload — scope contract", () => { expect(payload.label).toBe("Blood panel 2026"); }); - it("record share: keeps the full scope and omits documentIds when none picked", () => { + it("record share: keeps the window scope and omits documentIds when none picked", () => { const payload = buildShareLinkCreatePayload({ ...common, - allowFhirApi: false, - resourceTypes: ["Patient", "Observation"], documentIds: [], documentOnly: false, }); - expect(payload.resourceTypes).toEqual(["Patient", "Observation"]); + expect(payload.rangeEnd).toBeNull(); + expect(payload).not.toHaveProperty("resourceTypes"); + expect(payload).not.toHaveProperty("allowFhirApi"); expect(payload).not.toHaveProperty("documentIds"); expect(payload).not.toHaveProperty("documentOnly"); }); @@ -148,8 +146,6 @@ describe("buildShareLinkCreatePayload — scope contract", () => { it("record share: attaches the picked documents when the owner chose some", () => { const payload = buildShareLinkCreatePayload({ ...common, - allowFhirApi: false, - resourceTypes: ["Patient", "Observation"], documentIds: ["a", "b"], documentOnly: false, }); diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx index 81a5171f4..7cadc1354 100644 --- a/src/components/settings/__tests__/sharing-section.test.tsx +++ b/src/components/settings/__tests__/sharing-section.test.tsx @@ -33,8 +33,6 @@ const FIXTURE_LINKS = [ label: "Cardiology referral", rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: null, - resourceTypes: ["Patient", "Observation"], - allowFhirApi: true, documentCount: 2, expiresAt: "2099-01-01T00:00:00.000Z", createdAt: "2026-06-01T00:00:00.000Z", @@ -48,8 +46,6 @@ const FIXTURE_LINKS = [ label: "Old GP link", rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: null, - resourceTypes: [], - allowFhirApi: false, documentCount: 0, expiresAt: "2099-01-01T00:00:00.000Z", createdAt: "2026-05-01T00:00:00.000Z", @@ -100,8 +96,9 @@ describe(" — owner share-link surface (C7)", () => { // The list response shape carries no token; the markup must not surface // an `hls_` string anywhere. expect(html).not.toContain("hls_"); - // The FHIR badge surfaces for the FHIR-enabled link. - expect(html).toContain("FHIR"); + // No FHIR badge: the link has never served FHIR, so advertising + // it on the owner's list was the last place the promise still showed. + expect(html).not.toContain("FHIR"); }); it("folds revoked links into the inactive list (not the active list)", () => { diff --git a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts index 814683dc1..c7252dd65 100644 --- a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts +++ b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts @@ -39,8 +39,6 @@ function liveRow(overrides: Record = {}) { rangeStart: new Date("2026-01-01T00:00:00.000Z"), rangeEnd: null, sectionsJson: { bp: true }, - resourceTypes: ["Observation"], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), revokedAt: null, ...overrides, @@ -59,7 +57,9 @@ describe("resolveShareToken", () => { expect(ctx).not.toBeNull(); expect(ctx?.ownerUserId).toBe("owner-1"); expect(ctx?.shareLinkId).toBe("link-1"); - expect(ctx?.resourceTypes).toEqual(["Observation"]); + // The retired FHIR scope no longer rides the resolved context. + expect(ctx).not.toHaveProperty("resourceTypes"); + expect(ctx).not.toHaveProperty("allowFhirApi"); // The context must carry ONLY the owner scope — never a session/user/role. expect(ctx).not.toHaveProperty("session"); expect(ctx).not.toHaveProperty("user"); diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 5eac1b659..2317f375f 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -45,8 +45,6 @@ function ctx(overrides: Partial = {}): ShareContext { rangeStart: new Date("2026-01-01T00:00:00Z"), rangeEnd: new Date("2026-02-01T00:00:00Z"), sectionsJson: { mood: false }, - resourceTypes: [], - allowFhirApi: false, expiresAt: new Date(Date.now() + 86_400_000), ...overrides, } as ShareContext; diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index c3abd34fc..e8073ab02 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -109,7 +109,14 @@ describe("resolveDoctorReportPrefs", () => { familyHistory: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); - expect(out).toEqual({ ...current, mood: false }); + // Keys the persisted row predates (this change added the measurement-group, + // medication-list and screener flags) fill from the defaults, so the + // resolved shape is always complete for the aggregator. + expect(out).toEqual({ + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...current, + mood: false, + }); }); it("layers a partial update over defaults when current is null", () => { From 5e490f31ec0e30534d526a75a03ef1e83ad59e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 17:51:33 +0200 Subject: [PATCH 03/17] feat(export): make the doctor report carry only what was chosen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report resolved an absent key to "included". A caller that sent no sections object, or an empty group inside one, received the full record at server-chosen defaults: roughly fifty metrics plus the medication list, labs, allergies and family history. The web panel sent every key explicitly so it looked fine from the browser, but the share link, the FHIR REST face, the MCP doctor-visit resource and the cycle-only export card all went through paths where absence meant yes. No arrangement of booleans fixes a default that means yes, so the shape goes. A selection is now an inclusion list of leaf ids. A leaf is the smallest thing that can be released or withheld: every measurement type, and thirteen structured sections — identity, insurer, the glucose panel, labs, the drug list, the administration ledger, adherence, the GLP-1 block, allergies, illness episodes, family history, mood, cycle. Membership is inclusion, absence is exclusion, and there is no defaults constant anywhere to drift. Three locks keep a metric added later off a report chosen earlier. A stored selection is a list of what was chosen, so a leaf that did not exist then is not in it. The catalogue is keyed over the measurement enum, so adding a member without deciding its group fails typecheck in the same commit. And the aggregator's filter inverts: a type the catalogue does not know is excluded and annotated, where it used to be passed through. The aggregator's third parameter is now required and brand-typed, which turned the three surfaces that had no scope at all into compile errors. Each one had to name a source, and each now replays the owner's saved selection — the same object reviewed in Settings. An account that never saved one gets nothing there rather than a guess. Identity and insurance become selectable, and a share link refuses the insurance leaf outright. The share view never decrypted the number; that is structural now instead of incidental. The KVNR is not even read unless the leaf was chosen. Two surfaces gain a record they always implied. A share link serves its frozen selection as a FHIR bundle and as the PDF, behind the same unlock cookie the document route uses, with its own rate bucket, its own audit row and the same blunt 404 for every miss. That is what the removed FHIR toggle promised, in the form a practice actually uses. The AI summary leaves the report. Its text came from the cached briefing with no relation to the selection, so its scope could not be proven, and prose whose scope cannot be proven has no place on a document whose whole point is a proven scope. The PDF table stops being a nine-type whitelist. It prints every selected leaf that has data, sub-headed by group, in the same order the panel shows — so selecting resting heart rate no longer means seeing it in the bundle and not in the PDF. The clinician view is grouped the same way and reads real labels instead of "Phq9 score". Migration 0273 adds the saved-selection column, drops the orphaned prefs column and the two retired share-link scope columns, and revokes every live record share link minted before this model. Their frozen scope was "everything except mood and cycle", which is a scope nobody chose; narrowing it would still be a scope nobody chose. Documents-only shares are untouched. The sharing list shows each closed link in a pick-again state with its label, window and expiry kept. The retired allowFhirApi and resourceTypes stay on the create and list responses as the constant false and empty list. The request refuses them. They come off the response once the native client confirms it does not decode them. Six files are split at seams this change already touched: the aggregator into its gate, its dense-bucket query, its series, its glucose panel, its medication ledger and its clinical records; the export route's three emitters into their own module; the clinician view into its sections, its document list and its downloads. --- .../0273_report_selection/migration.sql | 29 + prisma/schema.prisma | 43 +- .../__tests__/route.test.ts | 210 --- .../api/auth/me/doctor-report-prefs/route.ts | 109 -- src/app/api/auth/me/report-selection/route.ts | 109 ++ src/app/api/export/health-record/route.ts | 288 ++-- src/app/api/meta/capabilities/route.ts | 26 +- src/app/api/share-links/route.ts | 92 +- src/app/c/[token]/fhir/route.ts | 46 + src/app/c/[token]/page.tsx | 38 +- src/app/c/[token]/report.pdf/route.ts | 51 + src/components/clinician/clinician-view.tsx | 333 +--- src/components/clinician/documents-list.tsx | 98 ++ src/components/clinician/download-actions.tsx | 46 + src/components/clinician/report-sections.tsx | 232 +++ src/lib/clinician-share/report-download.ts | 132 ++ src/lib/clinician-share/request-locale.ts | 26 + src/lib/clinician-share/share-view-data.ts | 31 +- src/lib/doctor-report-data.ts | 1430 +---------------- src/lib/doctor-report-pdf-core.ts | 94 +- .../clinical-records-notes-section.ts | 59 +- src/lib/doctor-report-pdf/clinical-summary.ts | 5 +- .../measurements-charts-section.ts | 93 +- src/lib/doctor-report-pdf/render-context.ts | 12 +- src/lib/doctor-report-selection.ts | 177 -- src/lib/doctor-report-types.ts | 26 +- src/lib/doctor-report/clinical-records.ts | 157 ++ src/lib/doctor-report/collect.ts | 577 +++++++ src/lib/doctor-report/dense-buckets.ts | 211 +++ src/lib/doctor-report/glucose-panel.ts | 146 ++ src/lib/doctor-report/measurement-series.ts | 161 ++ src/lib/doctor-report/medications.ts | 259 +++ src/lib/doctor-report/selection-gate.ts | 103 ++ src/lib/doctor-report/type-label-keys.ts | 24 - src/lib/export/health-record-artefacts.ts | 76 + src/lib/mcp/prompts.ts | 17 +- src/lib/mcp/resources.ts | 13 +- src/lib/measurements/type-label-keys.ts | 110 ++ src/lib/openapi/routes/meta.ts | 21 +- src/lib/report-selection/catalogue.ts | 380 +++++ src/lib/report-selection/saved-profile.ts | 72 + src/lib/report-selection/selection.ts | 131 ++ src/lib/report-selection/template.ts | 46 + src/lib/validations/clinician-share-link.ts | 26 +- src/lib/validations/doctor-report-prefs.ts | 228 --- src/lib/validations/health-record-export.ts | 224 +-- 46 files changed, 3734 insertions(+), 3083 deletions(-) create mode 100644 prisma/migrations/0273_report_selection/migration.sql delete mode 100644 src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/doctor-report-prefs/route.ts create mode 100644 src/app/api/auth/me/report-selection/route.ts create mode 100644 src/app/c/[token]/fhir/route.ts create mode 100644 src/app/c/[token]/report.pdf/route.ts create mode 100644 src/components/clinician/documents-list.tsx create mode 100644 src/components/clinician/download-actions.tsx create mode 100644 src/components/clinician/report-sections.tsx create mode 100644 src/lib/clinician-share/report-download.ts create mode 100644 src/lib/clinician-share/request-locale.ts delete mode 100644 src/lib/doctor-report-selection.ts create mode 100644 src/lib/doctor-report/clinical-records.ts create mode 100644 src/lib/doctor-report/collect.ts create mode 100644 src/lib/doctor-report/dense-buckets.ts create mode 100644 src/lib/doctor-report/glucose-panel.ts create mode 100644 src/lib/doctor-report/measurement-series.ts create mode 100644 src/lib/doctor-report/medications.ts create mode 100644 src/lib/doctor-report/selection-gate.ts delete mode 100644 src/lib/doctor-report/type-label-keys.ts create mode 100644 src/lib/export/health-record-artefacts.ts create mode 100644 src/lib/measurements/type-label-keys.ts create mode 100644 src/lib/report-selection/catalogue.ts create mode 100644 src/lib/report-selection/saved-profile.ts create mode 100644 src/lib/report-selection/selection.ts create mode 100644 src/lib/report-selection/template.ts delete mode 100644 src/lib/validations/doctor-report-prefs.ts diff --git a/prisma/migrations/0273_report_selection/migration.sql b/prisma/migrations/0273_report_selection/migration.sql new file mode 100644 index 000000000..7325c56c2 --- /dev/null +++ b/prisma/migrations/0273_report_selection/migration.sql @@ -0,0 +1,29 @@ +-- The owner's saved report selection: the leaf-id inclusion list plus the +-- format / range / charts choices the export panel restores. +ALTER TABLE "users" ADD COLUMN "report_selection_json" JSONB; + +-- Retire the orphaned flat prefs column. No code outside its own route ever +-- read it — the route's docblock claimed the aggregator consulted it, which +-- was not true — and the route is removed in the same release. +ALTER TABLE "users" DROP COLUMN "doctor_report_prefs_json"; + +-- Retire the share-link FHIR scope columns. The face they described was never +-- built and their controls are gone. An inert column with a plausible name is +-- precisely what a later contributor rewires by mistake. +ALTER TABLE "clinician_share_links" DROP COLUMN "resource_types"; +ALTER TABLE "clinician_share_links" DROP COLUMN "allow_fhir_api"; + +-- Revoke every live RECORD share link minted before the selection model. Their +-- frozen scope is `{}` or a flat prefs blob, both of which resolved to +-- "everything except mood and cycle" — a scope nobody chose. Downgrading them +-- to something narrower would still be a scope nobody chose, so they close and +-- the owner re-mints from the sharing list, which shows each one in a +-- pick-again state with its label, window and expiry preserved. +-- +-- Documents-only shares are untouched: they carry an explicit empty report +-- scope and serve no health metric. +UPDATE "clinician_share_links" + SET "revoked_at" = NOW() + WHERE "revoked_at" IS NULL + AND "document_only" = false + AND "expires_at" > NOW(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2aad4be6a..90ca68d2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -491,19 +491,22 @@ model User { // `MoodReminderDispatch` table (one row per user + local date). moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled") - // v1.4.25 W6c — Doctor-report section toggles. - // Shape: - // { "bp": boolean, - // "weight": boolean, - // "pulse": boolean, - // "bmi": boolean, - // "mood": boolean, // privacy-sensitive; default OFF - // "compliance": boolean, - // "sleep": boolean } - // Null = documented defaults (every section ON except mood). Mood is - // opt-in by default — the API layer drops mood entirely from the - // payload when the toggle is off so the data never leaves the DB row. - doctorReportPrefsJson Json? @map("doctor_report_prefs_json") + // The owner's saved report selection: the leaf-id inclusion list plus the + // format / range / charts choices the export panel restores. Shape: + // { "v": 2, + // "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …], + // "format": "pdf" | "fhir" | "package", + // "rangeDays": number, + // "includeCharts": boolean } + // Null = never saved, which resolves to the EMPTY selection everywhere. + // Read by the export panel, the share-link create form, the FHIR REST face + // and the MCP doctor-visit surfaces. + // + // Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not + // express "not chosen": an absent key resolved to included, so a caller who + // said nothing received the whole record. Membership is the whole contract + // here — absence is exclusion, and there is no defaults constant to drift. + reportSelectionJson Json? @map("report_selection_json") // v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob. // @@ -5378,18 +5381,10 @@ model ClinicianShareLink { rangeStart DateTime @map("range_start") /// Window end. Null = rolling ("up to now"); set = frozen snapshot window. rangeEnd DateTime? @map("range_end") - /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON. + /// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at + /// mint, never widened. A blob this build cannot read resolves to the empty + /// selection, so a drifted row serves nothing rather than defaulting open. sectionsJson Json @map("sections_json") - /// RETIRED. These two columns described a scoped FHIR face for a - /// share link that was never built: no route ever served FHIR (or any - /// machine-readable format) against an `hls_` token, so the create form's - /// toggle, its resource-type picker, the list badge and the API description - /// all promised reachability that did not exist. The controls are gone, the - /// create path no longer writes anything but the defaults, and no read path - /// touches either column — the values still on rows minted earlier are inert. - /// Kept as columns so no data is destroyed to undo a promise. - resourceTypes String[] @map("resource_types") - allowFhirApi Boolean @default(false) @map("allow_fhir_api") /// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at /// create. When true the public view serves ONLY the attached documents and /// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts deleted file mode 100644 index d993076dc..000000000 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, - toJson: (v: T) => v, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, PUT } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkPut(body: unknown): Request { - return new Request("http://localhost/api/auth/me/doctor-report-prefs", { - method: "PUT", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("GET /api/auth/me/doctor-report-prefs", () => { - it("returns defaults when the column is null", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - doctorReportPrefsJson: null, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/doctor-report-prefs"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: typeof DEFAULT_DOCTOR_REPORT_PREFS; - }; - expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS); - expect(env.data.mood).toBe(false); // privacy default per the maintainer - }); -}); - -describe("PUT /api/auth/me/doctor-report-prefs", () => { - // ── Case 1 — happy path ───────────────────────────────────────────── - it("persists the full shape and returns the canonical form", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - doctorReportPrefsJson: null, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const body = { - bp: true, - weight: true, - pulse: false, - bmi: true, - mood: true, - compliance: true, - sleep: false, - glucose: true, - cycle: false, - labs: true, - allergies: true, - familyHistory: false, - }; - const res = await (PUT as (r: Request) => Promise)(mkPut(body)); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: typeof body }; - // The canonical shape is the full prefs object: keys the body omits fill - // from the defaults rather than being dropped. - const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body }; - expect(env.data).toEqual(canonical); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { doctorReportPrefsJson: canonical }, - }); - }); - - // ── Case 2 — partial update layers over current persisted row ─────── - it("merges a partial update over the persisted row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const current = { - bp: false, - weight: false, - pulse: false, - bmi: false, - mood: true, - compliance: false, - sleep: false, - glucose: false, - cycle: false, - labs: false, - allergies: false, - familyHistory: false, - }; - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - doctorReportPrefsJson: current, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - // User only flips mood off — every other key keeps its persisted value. - const res = await (PUT as (r: Request) => Promise)( - mkPut({ mood: false }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: typeof current }; - const merged = { - ...DEFAULT_DOCTOR_REPORT_PREFS, - ...current, - mood: false, - }; - expect(env.data).toEqual(merged); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { doctorReportPrefsJson: merged }, - }); - }); - - // ── Case 3 — invalid shape ───────────────────────────────────────── - it("rejects a non-boolean value with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ bp: "nope" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - // ── Case 4 — unauthenticated cross-user request ──────────────────── - // the maintainer's spec mentions "cross-user 404" — the route layer returns 401 - // when there is no session at all, which is the only "you are not the - // right user" surface available before requireAuth() resolves an - // identity. `prisma.user.update` must NOT fire in this case. - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ mood: false }), - ); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - // ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ────── - it("writes an audit-log entry with the previous and new pref shape", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const current = { - bp: true, - weight: true, - pulse: true, - bmi: true, - mood: false, - compliance: true, - sleep: true, - }; - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - doctorReportPrefsJson: current, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ mood: true }), - ); - expect(res.status).toBe(200); - expect(auditLog).toHaveBeenCalledWith( - "user.doctor-report-prefs.update", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - previous: current, - next: expect.objectContaining({ mood: true }), - }), - }), - ); - }); -}); diff --git a/src/app/api/auth/me/doctor-report-prefs/route.ts b/src/app/api/auth/me/doctor-report-prefs/route.ts deleted file mode 100644 index a3fee47b7..000000000 --- a/src/app/api/auth/me/doctor-report-prefs/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * v1.4.25 W6c — per-user Doctor-Report section toggles. - * - * GET /api/auth/me/doctor-report-prefs — returns the resolved prefs - * (defaults when the row is null). - * PUT /api/auth/me/doctor-report-prefs — merges the supplied partial - * shape over the current row - * and persists the canonical - * fully-resolved object so a - * future schema migration - * doesn't need to back-fill - * missing keys. - * - * Bearer-auth + cookie-auth both work via the shared `requireAuth()` - * helper. The doctor-report aggregator + PDF renderer both read this - * row at generation time so mood data is dropped at the aggregator - * layer when `mood = false` — the data never leaves the DB. - */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess, getClientIp } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { prisma, toJson } from "@/lib/db"; -import { - doctorReportPrefsSchema, - parseDoctorReportPrefs, - resolveDoctorReportPrefs, -} from "@/lib/validations/doctor-report-prefs"; - -export const dynamic = "force-dynamic"; - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - annotate({ action: { name: "auth.me.doctor-report-prefs.get" } }); - - const row = await prisma.user.findUnique({ - where: { id: user.id }, - select: { doctorReportPrefsJson: true }, - }); - return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson)); -}); - -export const PUT = apiHandler(async (req: Request) => { - const { user } = await requireAuth(); - - let body: unknown; - try { - body = await req.json(); - } catch { - throw new HttpError(422, "doctor-report-prefs.body.invalid_json"); - } - - const parsed = doctorReportPrefsSchema.safeParse(body ?? {}); - if (!parsed.success) { - annotate({ - action: { name: "auth.me.doctor-report-prefs.put.invalid" }, - meta: { issues: parsed.error.issues.length }, - }); - throw new HttpError(422, "doctor-report-prefs.body.invalid_shape"); - } - - // Merge the partial input over the current row (or defaults) so a - // dialog that only toggles `mood` doesn't have to re-state every other - // flag. Persist the canonical fully-resolved object so the column - // shape stays stable across future schema additions. - const current = await prisma.user.findUnique({ - where: { id: user.id }, - select: { doctorReportPrefsJson: true }, - }); - const merged = resolveDoctorReportPrefs( - current?.doctorReportPrefsJson, - parsed.data, - ); - - await prisma.user.update({ - where: { id: user.id }, - data: { - doctorReportPrefsJson: toJson(merged), - }, - }); - - // v1.4.25 W10 reconcile (security M-3): record the previous + new - // pref shape in the audit log. Doctor-Report pref toggles - // (especially `mood: true`) widen the PDF surface; without an - // audit trail a silent compromise (or unintended client write) - // is invisible. Mirrors the timezone-route audit pattern. - await auditLog("user.doctor-report-prefs.update", { - userId: user.id, - ipAddress: getClientIp(req), - details: { - previous: current?.doctorReportPrefsJson ?? null, - next: merged, - }, - }); - - annotate({ - action: { name: "auth.me.doctor-report-prefs.put" }, - meta: { - bp: merged.bp, - weight: merged.weight, - pulse: merged.pulse, - bmi: merged.bmi, - mood: merged.mood, - compliance: merged.compliance, - sleep: merged.sleep, - }, - }); - return apiSuccess(merged); -}); diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts new file mode 100644 index 000000000..06b9706b0 --- /dev/null +++ b/src/app/api/auth/me/report-selection/route.ts @@ -0,0 +1,109 @@ +/** + * The owner's saved report selection. + * + * GET /api/auth/me/report-selection — the saved profile, or `null` when the + * account has never saved one. + * PUT /api/auth/me/report-selection — replace it. + * + * Replace, not merge. A selection is a list of what was chosen, so merging a + * partial over a stored one would re-introduce exactly the failure this shape + * removes: a leaf staying in because the caller did not mention it. The body + * states the whole scope or it is not a scope. + * + * Unknown leaf ids are refused with a 422 that names them. A client sending an + * id this build does not know is a contract mismatch the client has to see. + * + * Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing + * outside that route ever read. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma, toJson } from "@/lib/db"; +import { savedReportProfileSchema } from "@/lib/report-selection/saved-profile"; +import { selectionFromRequest } from "@/lib/report-selection/selection"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.report-selection.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { reportSelectionJson: true }, + }); + const parsed = savedReportProfileSchema.safeParse(row?.reportSelectionJson); + // A drifted or absent blob answers `null`, not a default profile: the panel + // then applies the named template and shows it, which is a visible act + // rather than a silent server choice. + return apiSuccess({ profile: parsed.success ? parsed.data : null }); +}); + +export const PUT = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "report-selection.body.invalid_json"); + } + + const parsed = savedReportProfileSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.report-selection.put.invalid" }, + meta: { issues: parsed.error.issues.length }, + }); + throw new HttpError(422, "report-selection.body.invalid_shape"); + } + + const minted = selectionFromRequest(parsed.data); + if (!minted.ok) { + annotate({ + action: { name: "auth.me.report-selection.put.invalid" }, + meta: { unknownLeaves: minted.error.unknownLeaves }, + }); + throw new HttpError(422, "report-selection.leaves.unknown"); + } + + // Persist the canonical ordering rather than the caller's, so two clients + // that chose the same scope store the same bytes. + const profile = { + v: parsed.data.v, + leaves: [...minted.selection.leaves], + format: parsed.data.format, + rangeDays: parsed.data.rangeDays, + includeCharts: parsed.data.includeCharts, + }; + + const current = await prisma.user.findUnique({ + where: { id: user.id }, + select: { reportSelectionJson: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { reportSelectionJson: toJson(profile) }, + }); + + // The saved selection is what the FHIR REST face and the MCP doctor-visit + // surfaces replay for a caller that cannot ask a human, so widening it + // widens those too. That belongs in the audit trail. + await auditLog("user.report-selection.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: current?.reportSelectionJson ?? null, + next: profile, + }, + }); + + annotate({ + action: { name: "auth.me.report-selection.put" }, + meta: { leafCount: profile.leaves.length, format: profile.format }, + }); + return apiSuccess({ profile }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index de9f67fe2..bf5a48e39 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -1,25 +1,25 @@ /** - * POST /api/export/health-record — v1.7.0 flagship health-record export. + * POST /api/export/health-record — the doctor-handover artefact. * - * One entry point that produces the doctor-handover artefact in one of three - * formats, driven by a strict Zod selection payload: - * - `pdf` → `application/pdf` (enhanced clinical report) + * One entry point producing one of three formats from one aggregated payload, + * driven by a strict Zod selection: + * - `pdf` → `application/pdf` * - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle) - * - `package` → `application/zip` (PDF + FHIR + README in one download) + * - `package` → `application/zip` (PDF + Bundle + README) * * Auth: cookie session OR Bearer token (`requireAuth`). - * Rate-limit: shared `export:` bucket (10/h) — so structured + PDF - * + CSV exports cannot be parallelised past the cap. - * Audit: `health-record.export` (records format/days/sections/charts — never - * the values). + * Rate-limit: shared `export:` bucket (10/h). + * Audit: `health-record.export` — format, window, and the chosen leaf ids, + * never the values behind them. * - * Strict validation: unlike the legacy doctor-report route (which tolerates - * drift), this route fails loudly — `.strict()` + `returnAllZodIssues` returns - * a 422 multi-issue envelope on any unknown key (including any attempt to - * smuggle a `userId`, which is always narrowed from `requireAuth()`). + * The `selection` field is required and carries the leaf inclusion list. An + * unknown leaf id is a 422 that names it; the old grouped `sections` shape is + * a 422 too, because accepting it would mean choosing a scope the caller did + * not express. The rendering itself lives in + * `src/lib/export/health-record-artefacts.ts`; this route keeps the HTTP + * concerns. */ import { NextRequest, NextResponse } from "next/server"; -import { zipSync, strToU8 } from "fflate"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -32,7 +32,7 @@ import { } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { prisma } from "@/lib/db"; +import { prisma, toJson } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto"; import { @@ -40,39 +40,46 @@ import { normaliseDateRange, sanitisePracticeName, } from "@/lib/doctor-report-data"; -import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; +import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/build-bundle"; +import { exportSelectionSchema } from "@/lib/validations/health-record-export"; +import { selectionFromRequest } from "@/lib/report-selection/selection"; import { - buildFhirDocumentBundle, - GERMAN_ATC_DEFAULT_LOCALES, -} from "@/lib/fhir/build-bundle"; -import { - exportSelectionSchema, - toDoctorReportPrefs, -} from "@/lib/validations/health-record-export"; + buildBundleJson, + buildPackageZip, + buildPdfBytes, + type ArtefactInputs, +} from "@/lib/export/health-record-artefacts"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; import { locales, type Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; /** - * Remember the practice / clinic name for the next report, so the user does - * not retype it every time (`User.lastReportPracticeName`, the preference the - * doctor-report contract has always promised). + * Remember what the owner chose, so the panel opens carrying it next time. * * Called only once the requested artefact has actually been produced: a - * generation that throws must not overwrite the remembered name with a - * half-typed one. `null` is a legitimate value — the user cleared the line and - * the next cover should stay cleared. The value is the already-sanitised one - * (`sanitisePracticeName`, 120-char cap), and the update names its single - * field explicitly rather than spreading the parsed selection. + * generation that throws must not overwrite the remembered scope or a + * half-typed practice name. `null` is a legitimate practice name — the user + * cleared the line and the next cover should stay cleared. Both fields are + * named explicitly rather than spread from the parsed body. */ -async function rememberPracticeName( +async function rememberChoices( userId: string, practiceName: string | null, + profile: { + v: 2; + leaves: string[]; + format: "pdf" | "fhir" | "package"; + rangeDays: number; + includeCharts: boolean; + }, ) { await prisma.user.update({ where: { id: userId }, - data: { lastReportPracticeName: practiceName }, + data: { + lastReportPracticeName: practiceName, + reportSelectionJson: toJson(profile), + }, }); } @@ -80,10 +87,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "export.health-record.build" } }); - // v1.18.0 B3 — the whole doctor-report / health-record surface is the - // `doctorReport` module. Refuse with a 403 `module.disabled` envelope when - // the account turned it off — even with a valid Bearer token. The settings - // entry-point already hides client-side; this is the hard enforcement. + // The whole doctor-report surface is the `doctorReport` module. Refuse with + // a 403 `module.disabled` envelope when the account turned it off — even + // with a valid Bearer token. const gate = await requireModuleEnabled(user.id, "doctorReport"); if (!gate.enabled) return gate.response; @@ -92,11 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - // The selection payload is small and bounded — format, range, - // section toggles, a few flags, an optional practice-name string. - // 64 KB is far above any legitimate selection while still rejecting - // a multi-megabyte body before it reaches `JSON.parse`. A DoS - // ceiling, not a tight bound. + // The selection payload is small and bounded. 64 KB is far above any + // legitimate one while still rejecting a multi-megabyte body before it + // reaches `JSON.parse`. A DoS ceiling, not a tight bound. const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); @@ -106,18 +110,26 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.success) { return returnAllZodIssues(parsed.error); } - const selection = parsed.data; + const input = parsed.data; - const range = normaliseDateRange(selection.range ?? undefined); - const practiceName = sanitisePracticeName(selection.practiceName); - const sections = toDoctorReportPrefs(selection.sections); - const includeCharts = selection.includeCharts ?? true; + const minted = selectionFromRequest(input.selection); + if (!minted.ok) { + annotate({ meta: { unknownLeaves: minted.error.unknownLeaves } }); + return apiError( + `Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`, + 422, + { errorCode: "export.selection.unknown_leaf" }, + ); + } + const selection = minted.selection; - // Locale resolution, most-specific first: the explicit selection wins, then - // the in-app `healthlog-locale` cookie (what the user actually chose in the - // UI), and only then the browser's Accept-Language header. The header is the - // weakest signal — a German user on an English-default browser would - // otherwise get an English report. + const range = normaliseDateRange(input.range ?? undefined); + const practiceName = sanitisePracticeName(input.practiceName); + const includeCharts = input.includeCharts ?? true; + + // Locale, most-specific first: the explicit selection wins, then the in-app + // cookie (what the user actually chose in the UI), and only then the + // browser's Accept-Language header — the weakest signal. const cookieValue = request.cookies.get("healthlog-locale")?.value; const cookieLocale = (locales as readonly string[]).includes( cookieValue ?? "", @@ -125,45 +137,34 @@ export const POST = apiHandler(async (request: NextRequest) => { ? (cookieValue as Locale) : null; const locale: Locale = - selection.locale ?? + input.locale ?? cookieLocale ?? parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? ""); - // BfArM ATC: an explicit selection flag wins; otherwise derive it from a - // German-region locale. The WHO ATC coding is unaffected either way. const germanAtc = - selection.germanAtc ?? + input.germanAtc ?? (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale); const [data, userTz, userRow, allergyRows, familyHistoryRows] = await Promise.all([ - collectDoctorReportData(user.id, range, { practiceName, sections }), + collectDoctorReportData(user.id, range, selection, { practiceName }), resolveUserTimezone(user.id), prisma.user.findUnique({ where: { id: user.id }, - select: { - insuranceNumberEncrypted: true, - insightsCachedText: true, - timeFormat: true, - }, + select: { insuranceNumberEncrypted: true, timeFormat: true }, }), - // v1.25 (W-RECORDS) — structured records are always-available reference - // data (not time-windowed), so they ride alongside the report rather - // than through `collectDoctorReportData`. Owner-scoped, live rows only. - // - // Gated on the section toggles. These two reads fed ONLY the - // FHIR bundle, and they ran unconditionally: `sections.allergies = false` - // produced a PDF without an allergy section and a bundle carrying every - // AllergyIntolerance, and in the `package` format both shipped in the - // same zip. Zero DB read when off, matching how the aggregator treats - // mood — deselected data should not be loaded, not merely dropped later. - sections.allergies + // Structured records are always-available reference data (not + // time-windowed), so they ride alongside the report rather than through + // the aggregator. Owner-scoped, live rows only — and each read is gated + // on its own leaf, so a deselected section is not loaded, not merely + // dropped later. + selection.has("ALLERGIES") ? prisma.allergy.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { createdAt: "desc" }, }) : Promise.resolve([]), - sections.familyHistory + selection.has("FAMILY_HISTORY") ? prisma.familyHistoryEntry.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { createdAt: "desc" }, @@ -171,16 +172,17 @@ export const POST = apiHandler(async (request: NextRequest) => { : Promise.resolve([]), ]); - // FHIR records folded into every bundle format (fhir + package). const fhirRecords = { allergies: allergyRows.map(toAllergyDTO), familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO), }; - // Decrypt the KVNR fail-soft: a key-rotation gap on one row should - // never 500 the export — the cover/identifier just omits the value. + // The KVNR is encrypted at rest and is its own leaf. It is neither read nor + // decrypted unless the insurance leaf was chosen. The decrypt is fail-soft: + // a key-rotation gap on one row omits the value rather than 500-ing the + // export. let insuranceNumber: string | null = null; - if (userRow?.insuranceNumberEncrypted) { + if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) { try { insuranceNumber = decrypt(userRow.insuranceNumberEncrypted); } catch { @@ -188,43 +190,53 @@ export const POST = apiHandler(async (request: NextRequest) => { } } - // AI summary is OUT of the clinical PDF by default — only included when - // the user explicitly toggled `includeAiSummary` AND a cached briefing - // text exists. Never reaches the FHIR bundle (not a clinical observation). - const aiSummary = - selection.includeAiSummary && userRow?.insightsCachedText - ? extractAiSummaryText(userRow.insightsCachedText) - : null; - const stamp = new Date().toISOString().slice(0, 10); + const { t } = getServerTranslator(locale); + + const artefact: ArtefactInputs = { + data, + insuranceNumber, + records: fhirRecords, + germanAtc, + includeCharts, + locale, + userTz, + timeFormat: userRow?.timeFormat ?? "AUTO", + t, + }; + + const profile = { + v: 2 as const, + leaves: [...selection.leaves], + format: input.format, + rangeDays: Math.min(365, Math.max(1, range.days)), + includeCharts, + }; await auditLog("health-record.export", { userId: user.id, ipAddress: getClientIp(request), details: { - format: selection.format, + format: input.format, days: range.days, startDate: range.start.toISOString(), endDate: range.end.toISOString(), charts: includeCharts, - aiSummary: aiSummary !== null, - // Section toggles only (booleans) — never the underlying values. - sections, + // The chosen leaf ids — never the values behind them. + leaves: profile.leaves, }, }); - if (selection.format === "fhir") { - const bundle = buildFhirDocumentBundle( - data, - { insuranceNumber }, - undefined, - { germanAtc }, - fhirRecords, - ); - const json = JSON.stringify(bundle); - await rememberPracticeName(user.id, practiceName); + if (input.format === "fhir") { + const json = buildBundleJson(artefact); + await rememberChoices(user.id, practiceName, profile); annotate({ - meta: { format: "fhir", bytes: json.length, days: range.days }, + meta: { + format: "fhir", + bytes: json.length, + days: range.days, + leafCount: profile.leaves.length, + }, }); return new NextResponse(json, { status: 200, @@ -236,23 +248,19 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { t } = getServerTranslator(locale); - const pdfBytes = renderDoctorReportPdfBytes(data, { - t, - locale, - userTz, - timeFormat: userRow?.timeFormat ?? "AUTO", - insuranceNumber, - includeCharts, - aiSummary, - }); - // Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the - // artefact exists from here on. - await rememberPracticeName(user.id, practiceName); + const pdfBytes = buildPdfBytes(artefact); + // Both remaining formats carry the rendered PDF, so the artefact exists from + // here on. + await rememberChoices(user.id, practiceName, profile); - if (selection.format === "pdf") { + if (input.format === "pdf") { annotate({ - meta: { format: "pdf", bytes: pdfBytes.byteLength, days: range.days }, + meta: { + format: "pdf", + bytes: pdfBytes.byteLength, + days: range.days, + leafCount: profile.leaves.length, + }, }); const buffer = pdfBytes.slice().buffer; return new NextResponse(buffer, { @@ -266,25 +274,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - // format === "package": one zip holding the PDF + FHIR Bundle + a README. - const bundle = buildFhirDocumentBundle( - data, - { insuranceNumber }, - undefined, - { germanAtc }, - fhirRecords, - ); - const readme = t("doctorReport.packageReadme"); - const zipped = zipSync( - { - "report.pdf": pdfBytes, - "bundle.json": strToU8(JSON.stringify(bundle, null, 2)), - "README.txt": strToU8(readme), - }, - { level: 6 }, - ); + const zipped = buildPackageZip(artefact, pdfBytes); annotate({ - meta: { format: "package", bytes: zipped.byteLength, days: range.days }, + meta: { + format: "package", + bytes: zipped.byteLength, + days: range.days, + leafCount: profile.leaves.length, + }, }); const zipBuffer = zipped.slice().buffer; return new NextResponse(zipBuffer, { @@ -297,26 +294,3 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); }); - -/** - * Extract plain summary text from the cached insights JSON blob. The cache - * is provider-shaped JSON; we pull the human-readable summary field if - * present, else fall back to a trimmed stringification. Always plain text - * (no markdown rendering — the PDF prints React-free string children). - */ -function extractAiSummaryText(raw: string): string | null { - try { - const parsed = JSON.parse(raw) as Record; - const candidate = - (typeof parsed.summary === "string" && parsed.summary) || - (typeof parsed.text === "string" && parsed.text) || - (typeof parsed.briefing === "string" && parsed.briefing) || - null; - if (candidate) return candidate.trim().slice(0, 4000); - return null; - } catch { - // Not JSON — treat as plain text. - const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed.slice(0, 4000) : null; - } -} diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts index 1d39f38d3..8db19ac72 100644 --- a/src/app/api/meta/capabilities/route.ts +++ b/src/app/api/meta/capabilities/route.ts @@ -47,7 +47,10 @@ import { FHIR_SEARCH_PARAMS, } from "@/lib/fhir/rest"; import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; -import { exportSectionsSchema } from "@/lib/validations/health-record-export"; +import { + ALL_LEAF_IDS, + REPORT_GROUP_ORDER, +} from "@/lib/report-selection/catalogue"; export const dynamic = "force-dynamic"; @@ -113,17 +116,22 @@ export const GET = apiHandler(async () => { operations: [FHIR_EVERYTHING_OPERATION], searchParams: FHIR_SEARCH_PARAMS, }, - // Clinician share-link surface (v1.11): a scoped, time-boxed, revocable - // read-only link to the owner's record. Descriptor sourced from the - // canonical share validation + export-section constants. The share serves - // the rendered record view only; no `/api/fhir/*` route honours a share - // token yet (they require an authenticated `fhir:read` Bearer), so the - // share→FHIR face is not advertised as live. + // Clinician share-link surface: a scoped, time-boxed, revocable read-only + // link to the owner's record, sourced from the canonical share validation + // and the selection catalogue. + // + // `reportDownload` replaces the old `fhirApi: false`, which described a + // face that was never built. The record IS reachable in machine form now: + // as a download under the token, behind the same unlock the page uses, + // serving exactly the link's frozen selection. No bearer face, no new + // scope — `/api/fhir/*` still refuses a share token. share: { supported: true, maxDays: SHARE_LINK_MAX_DAYS, - fhirApi: false, - sections: Object.keys(exportSectionsSchema.shape), + reportDownload: ["fhir", "pdf"], + selectionVersion: 2, + groups: REPORT_GROUP_ORDER, + leaves: ALL_LEAF_IDS, }, }); }); diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 8f8fb22f3..a98fd2fc8 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -28,8 +28,16 @@ import { } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; -import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; -import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; +import { + createShareLinkSchema, + SHARE_LINK_FORBIDDEN_LEAVES, +} from "@/lib/validations/clinician-share-link"; +import { + EMPTY_REPORT_SELECTION, + reportSelectionSchema, + selectionFromRequest, + selectionToBlob, +} from "@/lib/report-selection/selection"; import { generatePassphrase, hashPassphrase, @@ -66,6 +74,10 @@ const shareLinkSummarySelect = { rangeStart: true, rangeEnd: true, documentOnly: true, + // Read to answer one owner-facing question: does this link's frozen scope + // predate the selection model? Never returned — only the boolean derived + // from it is. + sectionsJson: true, passphraseHash: true, expiresAt: true, createdAt: true, @@ -75,6 +87,22 @@ const shareLinkSummarySelect = { _count: { select: { documents: true } }, } as const; +/** + * RETIRED, kept on the response only. + * + * These two fields described a scoped FHIR face for a share link that was + * never built. The request schema refuses them — sending either is a 422 — + * their columns are dropped, and nothing reads or honours them. They stay in + * the create and list responses, serving the constant `false` and `[]`, + * because the native client was given this response shape and a missing + * non-optional key fails a whole Swift decode. They come out once #70 in the + * native repository confirms the client does not decode them. + */ +const RETIRED_FHIR_SCOPE_FIELDS = { + allowFhirApi: false, + resourceTypes: [] as string[], +} as const; + /** Project a stored row to the safe owner-facing shape (never the token). */ function toSummary(row: { id: string; @@ -82,6 +110,7 @@ function toSummary(row: { rangeStart: Date; rangeEnd: Date | null; documentOnly: boolean; + sectionsJson: unknown; passphraseHash: string | null; expiresAt: Date; createdAt: Date; @@ -98,6 +127,14 @@ function toSummary(row: { // v1.28.16 — the frozen documents-only flag (owner-facing; the serve path // gates on the column, not on "are all sections off?"). documentOnly: row.documentOnly, + // A record link whose frozen scope predates the selection model. The + // v1.32.37 migration revoked every one of them, because their scope was + // "everything except mood and cycle" — a scope nobody chose. The sharing + // list surfaces this so the owner sees why the link closed and can re-mint + // with the same label, window and expiry, stopped at the selection step. + needsReselection: + !row.documentOnly && + !reportSelectionSchema.safeParse(row.sectionsJson).success, // v1.18.7 — surface only WHETHER a passphrase guards the link, never the // hash itself. Legacy (null-hash) links read `false` and stay ungated. protected: row.passphraseHash !== null, @@ -134,16 +171,41 @@ export const POST = apiHandler(async (request: NextRequest) => { } const input = parsed.data; - // v1.28.13 — a documents-only share carries NO report scope. Force the scope - // column to empty here (not just at the client) so a document link can never - // serve a single health metric, whatever the body tried to smuggle: an - // explicit all-OFF prefs blob, distinct from the `{}` that means "full record - // defaults". "Share this document" means the document, not the whole record. + // v1.28.13 — a documents-only share carries NO report scope. Force it empty + // here (not just at the client) so a document link can never serve a single + // health metric, whatever the body tried to smuggle. "Share this document" + // means the document, not the whole record. const documentOnly = input.documentOnly === true; - const sectionsJson = documentOnly - ? EMPTY_DOCTOR_REPORT_PREFS - : (input.sections ?? {}); - annotate({ meta: { documentOnly } }); + + let selection = EMPTY_REPORT_SELECTION; + if (!documentOnly && input.selection) { + const minted = selectionFromRequest(input.selection); + if (!minted.ok) { + return apiError( + `Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`, + 422, + { errorCode: "share-link.selection.unknown_leaf" }, + ); + } + selection = minted.selection; + } + + // The insurance leaf is refused on a share link. The share view has never + // decrypted the insurance number, and refusing it here makes that structural + // rather than incidental: there is no path by which a link could carry it. + const forbidden = SHARE_LINK_FORBIDDEN_LEAVES.filter((leaf) => + selection.has(leaf), + ); + if (forbidden.length > 0) { + return apiError(`A share link cannot carry: ${forbidden.join(", ")}`, 422, { + errorCode: "share-link.selection.forbidden_leaf", + }); + } + + const sectionsJson = selectionToBlob(selection); + annotate({ + meta: { documentOnly, leafCount: selection.leaves.length }, + }); // Mint the 192-bit raw token, store only its HMAC hash. const rawToken = `hls_${randomBytes(24).toString("hex")}`; @@ -217,6 +279,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiSuccess( { ...toSummary(created), + ...RETIRED_FHIR_SCOPE_FIELDS, token: rawToken, passphrase: rawPassphrase, shareUrl, @@ -236,5 +299,10 @@ export const GET = apiHandler(async () => { select: shareLinkSummarySelect, }); - return apiSuccess({ shareLinks: rows.map(toSummary) }); + return apiSuccess({ + shareLinks: rows.map((row) => ({ + ...toSummary(row), + ...RETIRED_FHIR_SCOPE_FIELDS, + })), + }); }); diff --git a/src/app/c/[token]/fhir/route.ts b/src/app/c/[token]/fhir/route.ts new file mode 100644 index 000000000..82a25f1d6 --- /dev/null +++ b/src/app/c/[token]/fhir/route.ts @@ -0,0 +1,46 @@ +/** + * GET /c/{token}/fhir — the share link's record as an HL7 FHIR R4 document + * Bundle, as a download. + * + * Behind the same unlock cookie the page and the document route use, serving + * exactly the link's frozen selection. The insurance number is never carried: + * the insurance leaf is refused at share-link creation, so there is nothing to + * decrypt here and the identity block is built without it. + * + * The gate itself lives in `@/lib/clinician-share/report-download` so this + * route and the PDF beside it cannot drift apart. + */ +import { NextResponse } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { resolveShareReportDownload } from "@/lib/clinician-share/report-download"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; + +type RouteParams = { params: Promise<{ token: string }> }; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler( + async (request: Request, { params }: RouteParams) => { + const { token } = await params; + const resolved = await resolveShareReportDownload(request, token, "fhir"); + if (!resolved.ok) return resolved.response; + + // No structured records ride a share bundle: allergies and family history + // reach the owner's own export through a separate read that this surface + // does not perform, so the bundle carries exactly the aggregated payload + // the page renders and nothing beside it. + const bundle = buildFhirDocumentBundle(resolved.report, { + insuranceNumber: null, + }); + const stamp = new Date().toISOString().slice(0, 10); + return new NextResponse(JSON.stringify(bundle), { + status: 200, + headers: { + "Content-Type": "application/fhir+json; charset=utf-8", + "Content-Disposition": `attachment; filename="healthlog-fhir-${stamp}.json"`, + "Cache-Control": "no-store", + }, + }); + }, +); diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 51582adcf..cfef75753 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -16,7 +16,7 @@ * string renders as escaped React text children (XSS posture). */ import type { Metadata } from "next"; -import { cookies, headers } from "next/headers"; +import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { @@ -29,8 +29,7 @@ import { verifyUnlockValue, } from "@/lib/clinician-share/unlock-cookie"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; -import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; +import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { ClinicianView } from "@/components/clinician/clinician-view"; import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate"; @@ -45,20 +44,6 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -async function resolveLocale(): Promise { - try { - const cookieStore = await cookies(); - const cookieLocale = cookieStore.get("healthlog-locale")?.value; - if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) { - return cookieLocale as Locale; - } - const headerList = await headers(); - return parseLocaleFromAcceptLanguage(headerList.get("accept-language")); - } catch { - return defaultLocale; - } -} - export default async function ClinicianSharePage({ params, }: { @@ -81,7 +66,7 @@ export default async function ClinicianSharePage({ unlockCookieName(gate.tokenHash), )?.value; if (!verifyUnlockValue(unlockValue, gate.tokenHash)) { - const locale = await resolveLocale(); + const locale = await resolveShareViewLocale(); const { t } = getServerTranslator(locale); // Resolve the gate copy on the server (it owns the locale) into a plain, // serializable object. A `t` function cannot cross the RSC boundary into @@ -107,12 +92,15 @@ export default async function ClinicianSharePage({ // Issue #490 — period/expiry dates render in the share OWNER's (patient's) // profile timezone so they agree with the patient-tz aggregation behind the // stats and with the doctor-report PDF (never the container's zone). - const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] = - await Promise.all([ - loadShareViewData(context), - resolveLocale(), - resolveUserTimezone(context.ownerUserId), - ]); + const [ + { report, selection, documents, documentOnly }, + locale, + ownerTimezone, + ] = await Promise.all([ + loadShareViewData(context), + resolveShareViewLocale(), + resolveUserTimezone(context.ownerUserId), + ]); const { t } = getServerTranslator(locale); return ( @@ -121,7 +109,7 @@ export default async function ClinicianSharePage({ label={context.label} expiresAt={context.expiresAt.toISOString()} report={report} - sections={sections} + selection={selection} documents={documents} documentOnly={documentOnly} token={token} diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts new file mode 100644 index 000000000..e2e4dd281 --- /dev/null +++ b/src/app/c/[token]/report.pdf/route.ts @@ -0,0 +1,51 @@ +/** + * GET /c/{token}/report.pdf — the share link's record as the clinical PDF. + * + * Behind the same unlock cookie the page and the document route use, serving + * exactly the link's frozen selection. Charts are embedded, matching the + * owner's own default; the practice-name cover line is whatever the owner + * froze onto the report, which for a share link is nothing. + * + * The gate itself lives in `@/lib/clinician-share/report-download` so this + * route and the bundle beside it cannot drift apart. + */ +import { NextResponse } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { resolveShareReportDownload } from "@/lib/clinician-share/report-download"; +import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale"; + +type RouteParams = { params: Promise<{ token: string }> }; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler( + async (request: Request, { params }: RouteParams) => { + const { token } = await params; + const resolved = await resolveShareReportDownload(request, token, "pdf"); + if (!resolved.ok) return resolved.response; + + // The recipient's locale, not the owner's: this document is being read by + // the practice, and the page above it already renders in the same one. + const locale = await resolveShareViewLocale(); + const { t } = getServerTranslator(locale); + const bytes = renderDoctorReportPdfBytes(resolved.report, { + t, + locale, + insuranceNumber: null, + includeCharts: true, + }); + const stamp = new Date().toISOString().slice(0, 10); + return new NextResponse(bytes.slice().buffer, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="healthlog-report-${stamp}.pdf"`, + "Content-Length": String(bytes.byteLength), + "Cache-Control": "no-store", + }, + }); + }, +); diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index d6d583b1c..89e367317 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -1,26 +1,34 @@ /** - * v1.11.0 — clinician-view presentation (Epic C, C5). + * The public clinician view. * * A pure server component: it receives an already-resolved, owner-scoped * {@link DoctorReportData} plus a server-side translator and renders a - * read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO + * read-only clinical summary. NO client hooks, NO session, NO AI or coach, NO * markdown — every value renders as escaped React text. * - * Layout: provenance header → clinical vitals/labs → medications + adherence → - * a FENCED, muted wellness card carrying the load-bearing "descriptive, not a - * clinical assessment / not a diagnosis" disclaimer. + * Layout: provenance header (with the two machine-format downloads) → the + * measurement groups in selection order → glucose → medications and adherence + * → a FENCED, muted wellness card carrying the load-bearing "descriptive, not + * a clinical assessment" disclaimer → the attached documents. + * + * The section components live in `./report-sections`, the document list in + * `./documents-list`, the downloads in `./download-actions`. */ -import { Download } from "lucide-react"; - -import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { formatBytes } from "@/components/documents/vault-utils"; -import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; -import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import { makeFormatters } from "@/lib/format-locale"; import type { Locale } from "@/lib/i18n/config"; -import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; -import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; +import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; +import type { ReportSelection } from "@/lib/report-selection/selection"; +import { DocumentEntry } from "./documents-list"; +import { ShareDownloadActions } from "./download-actions"; +import { + GlucoseSection, + MeasurementGroups, + MedicationsSection, + Section, + StatRow, + WellnessSection, +} from "./report-sections"; type Translate = ( key: string, @@ -34,22 +42,22 @@ interface ClinicianViewProps { /** ISO expiry instant — surfaced so the clinician knows the link lifetime. */ expiresAt: string; /** - * The owner-scoped report payload, or `null` for a documents-only share - * (no report section enabled). When `null` NO health metric is rendered — - * only the header, disclaimer, and the attached documents. + * The owner-scoped report payload, or `null` for a documents-only share. + * When `null` NO health metric is rendered — only the header, the + * disclaimer, and the attached documents. */ report: DoctorReportData | null; - sections: DoctorReportPrefs; + /** The link's frozen selection, resolved. */ + selection: ReportSelection; /** - * v1.28.13 — a documents-only link. Hides the reporting-period line (there is - * no report) and, together with a `null` report, keeps every health section - * off the page. "Share this document" means the document, not the record. + * A documents-only link. Hides the reporting-period line (there is no + * report) and, together with a `null` report, keeps every health section off + * the page. */ documentOnly?: boolean; /** * The frozen document set on this link (metadata only — never bytes). Each - * entry points at the token-scoped serve route (`/c//d/`), the - * one decrypt path. Empty when the owner shared no documents. + * entry points at the token-scoped serve route, the one decrypt path. */ documents?: ShareViewDocument[]; /** The raw share token from the path — used to build serve-route URLs. */ @@ -57,154 +65,19 @@ interface ClinicianViewProps { /** Viewer locale (byte formatting only). Defaults to English. */ locale?: Locale; /** - * Issue #490 — the share OWNER's (patient's) profile timezone. Period - * start/end and the expiry date render in this zone so the dates agree - * with the patient-tz aggregation behind the stats (and with the - * doctor-report PDF). Pre-fix the dates rendered via a bare - * `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither - * the patient's nor the viewer's, with US-English field order regardless - * of the resolved page locale. + * Issue #490 — the share OWNER's profile timezone. Period start/end and the + * expiry date render in this zone so the dates agree with the patient-tz + * aggregation behind the stats (and with the doctor-report PDF). */ timezone?: string; } -/** Human-readable display per persisted wellness-score type (i18n key suffix). */ -const WELLNESS_KEY: Record = { - RECOVERY_SCORE: "recovery", - STRESS_SCORE: "stress", - STRAIN_SCORE: "strain", -}; - -/** - * Localised label for a measurement-type enum, reusing the doctor-report type - * key map so the clinician view reads in the viewer's locale alongside the rest - * of the page. A type with no key falls back to a humanised enum form - * (`BLOOD_PRESSURE_SYS` → "Blood pressure sys"). - */ -function typeLabel(type: string, t: Translate): string { - const key = DOCTOR_REPORT_TYPE_LABEL_KEYS[type]; - if (key) return t(key); - const lower = type.replace(/_/g, " ").toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); -} - -/** Render a single labelled stat row. */ -function StatRow({ label, value }: { label: string; value: string }) { - return ( -

- {label} - {value} -
- ); -} - -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

{title}

- {children} -
- ); -} - -/** - * One shared document. Class A (inline) renders a preview straight at the - * token-scoped serve route — an `` for raster images, a framed PDF - * otherwise; the proxy's share-serve CSP (`default-src 'none'; - * frame-ancestors 'self'`) fences the framed context. Class B (attachment) - * offers a plain download link — the serve route answers it as an opaque - * `application/octet-stream` attachment. No bytes reach this component; only - * the metadata and the URL. - */ -function DocumentEntry({ - t, - doc, - token, - locale, -}: { - t: Translate; - doc: ShareViewDocument; - token: string; - locale: string; -}) { - const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`; - const title = doc.title ?? t("clinicianView.documents.untitled"); - const Icon = - DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined; - const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter( - Boolean, - ) as string[]; - const isImage = doc.mimeType.startsWith("image/"); - const isInline = doc.servingClass === "inline"; - - return ( -
  • -
    -
    - {Icon ? ( - - ) : null} -
    -

    {title}

    - {meta.length > 0 ? ( -

    - {meta.join(" · ")} -

    - ) : null} -
    -
    - {/* Download is always available (inline or attachment) so the recipient - can keep a copy; the anchor hits the same token-scoped serve route. */} - - - {t("clinicianView.documents.download")} - -
    - - {isInline ? ( -
    - {isImage ? ( - // The served image is the frozen original with EXIF/GPS stripped on - // egress; it renders inline within the Class A carve-out. - // eslint-disable-next-line @next/next/no-img-element - {title} - ) : ( -