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] 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, }; }