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