From 68b9903966f2a254cebe65509f1e60309e81995a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 17:22:33 +0200
Subject: [PATCH 01/17] fix(export): make the doctor-report selection controls
tell the truth
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.
Three more controls were untruthful in their own way:
- The medications switch documented itself as excluding all medication data
and excluded the adherence figures only. The drug list and the per-dose
administration ledger rode along regardless. Both follow the switch now;
the rows are still read, because compliance and the GLP-1 block are computed
from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
unconditionally for the FHIR bundle, so a package could carry a compliant
PDF and a violating bundle in the same zip. The reads are gated, and a
deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
They rode the mental-health module alone, which has been default-on since
v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
links and the MCP doctor-visit resource on accounts that never chose to send
them. They get their own section key, defaulting off, with the module gate
kept as the second lock, and the stale privacy-by-default comment that
argued otherwise is corrected.
The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.
Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
---
docs/api/openapi.yaml | 44 +----
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 4 +-
prisma/schema.prisma | 15 +-
src/app/api/export/health-record/route.ts | 27 ++-
src/app/api/share-links/route.ts | 21 +--
.../settings/health-record-export-controls.ts | 102 ++++++++++
.../settings/health-record-export-panel.tsx | 91 ++-------
.../settings/share-link-create-form.tsx | 97 +---------
src/components/settings/sharing-section.tsx | 5 -
.../clinician-share/resolve-share-token.ts | 8 -
src/lib/doctor-report-data.ts | 174 +++++------------
src/lib/doctor-report-selection.ts | 177 ++++++++++++++++++
src/lib/openapi/routes/coach.ts | 2 +-
src/lib/openapi/routes/health-record.ts | 2 -
src/lib/validations/clinician-share-link.ts | 38 ++--
src/lib/validations/doctor-report-prefs.ts | 68 +++++++
src/lib/validations/health-record-export.ts | 40 +++-
22 files changed, 509 insertions(+), 426 deletions(-)
create mode 100644 src/components/settings/health-record-export-controls.ts
create mode 100644 src/lib/doctor-report-selection.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d9448a580..2889c47f5 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -938,8 +938,6 @@ paths:
type: boolean
distance:
type: boolean
- energy:
- type: boolean
sleep:
type: boolean
glucose:
@@ -951,10 +949,6 @@ paths:
type: boolean
compliance:
type: boolean
- glp1:
- type: boolean
- sideEffects:
- type: boolean
mood:
type: boolean
bmi:
@@ -967,6 +961,8 @@ paths:
type: boolean
familyHistory:
type: boolean
+ mentalHealthScreeners:
+ type: boolean
locale:
type: string
enum:
@@ -1077,8 +1073,6 @@ paths:
type: boolean
distance:
type: boolean
- energy:
- type: boolean
sleep:
type: boolean
glucose:
@@ -1090,10 +1084,6 @@ paths:
type: boolean
compliance:
type: boolean
- glp1:
- type: boolean
- sideEffects:
- type: boolean
mood:
type: boolean
bmi:
@@ -1106,18 +1096,8 @@ paths:
type: boolean
familyHistory:
type: boolean
- resourceTypes:
- maxItems: 8
- type: array
- items:
- type: string
- enum:
- - Patient
- - Observation
- - MedicationStatement
- - MedicationAdministration
- allowFhirApi:
- type: boolean
+ mentalHealthScreeners:
+ type: boolean
documentIds:
maxItems: 50
type: array
@@ -15735,12 +15715,6 @@ components:
anyOf:
- type: string
- type: "null"
- resourceTypes:
- type: array
- items:
- type: string
- allowFhirApi:
- type: boolean
protected:
type: boolean
documentCount:
@@ -15779,8 +15753,6 @@ components:
- label
- rangeStart
- rangeEnd
- - resourceTypes
- - allowFhirApi
- protected
- documentCount
- expiresAt
@@ -15827,12 +15799,6 @@ components:
anyOf:
- type: string
- type: "null"
- resourceTypes:
- type: array
- items:
- type: string
- allowFhirApi:
- type: boolean
protected:
type: boolean
documentCount:
@@ -15858,8 +15824,6 @@ components:
- label
- rangeStart
- rangeEnd
- - resourceTypes
- - allowFhirApi
- protected
- documentCount
- expiresAt
diff --git a/messages/de.json b/messages/de.json
index 0457825b4..7e30c8472 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5331,6 +5331,7 @@
"glucose": "Blutzucker",
"medications": "Medikamente & Therapietreue",
"mood": "Stimmung (standardmäßig aus — sensible Daten)",
+ "mentalHealthScreeners": "Psychische Screenings (PHQ-9, GAD-7, WHO-5, SCI — standardmäßig aus)",
"bmi": "BMI",
"includeCharts": "Trend-Diagramme einbetten (nur PDF)",
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
@@ -5354,9 +5355,6 @@
"expiry": "Läuft ab in (Tagen)",
"expiryHint": "Höchstens {max} Tage.",
"expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.",
- "fhirApi": "Eingegrenzte FHIR-API erlauben",
- "fhirApiHint": "Lasse den Link zusätzlich zur Seitenansicht einen schreibgeschützten FHIR-Endpunkt bereitstellen.",
- "resourceTypes": "FHIR-Ressourcentypen",
"create": "Link erstellen",
"tokenCreated": "Freigabe-Link erstellt",
"copied": "In die Zwischenablage kopiert.",
diff --git a/messages/en.json b/messages/en.json
index 37e2e2186..4adcf54d7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5321,6 +5321,7 @@
"glucose": "Blood glucose",
"medications": "Medications & adherence",
"mood": "Mood (off by default — sensitive data)",
+ "mentalHealthScreeners": "Mental-health screening (PHQ-9, GAD-7, WHO-5, SCI — off by default)",
"bmi": "BMI",
"includeCharts": "Embed trend charts (PDF only)",
"includeAiSummary": "Append AI summary (not clinically validated)",
@@ -5344,9 +5345,6 @@
"expiry": "Expires in (days)",
"expiryHint": "At most {max} days.",
"expiryInvalid": "Expiry must be between 1 and {max} days.",
- "fhirApi": "Allow scoped FHIR API",
- "fhirApiHint": "Let the link serve a read-only FHIR endpoint in addition to the page view.",
- "resourceTypes": "FHIR resource types",
"create": "Create link",
"tokenCreated": "Share link created",
"copied": "Copied to clipboard.",
diff --git a/messages/es.json b/messages/es.json
index 5430a002a..5b39c06ac 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5333,6 +5333,7 @@
"glucose": "Glucosa en sangre",
"medications": "Medicamentos y adherencia",
"mood": "Estado de ánimo (desactivado por defecto — datos sensibles)",
+ "mentalHealthScreeners": "Cribado de salud mental (PHQ-9, GAD-7, WHO-5, SCI — desactivado por defecto)",
"bmi": "IMC",
"includeCharts": "Incrustar gráficos de tendencia (solo PDF)",
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
@@ -5356,9 +5357,6 @@
"expiry": "Caduca en (días)",
"expiryHint": "Como máximo {max} días.",
"expiryInvalid": "La caducidad debe estar entre 1 y {max} días.",
- "fhirApi": "Permitir API FHIR acotada",
- "fhirApiHint": "Deja que el enlace sirva un endpoint FHIR de solo lectura además de la vista de página.",
- "resourceTypes": "Tipos de recurso FHIR",
"create": "Crear enlace",
"tokenCreated": "Enlace para compartir creado",
"copied": "Copiado al portapapeles.",
diff --git a/messages/fr.json b/messages/fr.json
index 0bc0a7a47..c44e23a39 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5335,6 +5335,7 @@
"glucose": "Glycémie",
"medications": "Médicaments & observance",
"mood": "Humeur (désactivé par défaut — données sensibles)",
+ "mentalHealthScreeners": "Dépistage de santé mentale (PHQ-9, GAD-7, WHO-5, SCI — désactivé par défaut)",
"bmi": "IMC",
"includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)",
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
@@ -5358,9 +5359,6 @@
"expiry": "Expire dans (jours)",
"expiryHint": "Au plus {max} jours.",
"expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.",
- "fhirApi": "Autoriser l'API FHIR restreinte",
- "fhirApiHint": "Laissez le lien servir un point d'accès FHIR en lecture seule en plus de la vue page.",
- "resourceTypes": "Types de ressource FHIR",
"create": "Créer le lien",
"tokenCreated": "Lien de partage créé",
"copied": "Copié dans le presse-papiers.",
diff --git a/messages/it.json b/messages/it.json
index 8064f1679..c562f129e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5325,6 +5325,7 @@
"glucose": "Glicemia",
"medications": "Farmaci e aderenza",
"mood": "Umore (disattivato di default — dati sensibili)",
+ "mentalHealthScreeners": "Screening di salute mentale (PHQ-9, GAD-7, WHO-5, SCI — disattivato di default)",
"bmi": "BMI",
"includeCharts": "Incorpora grafici di tendenza (solo PDF)",
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
@@ -5348,9 +5349,6 @@
"expiry": "Scade tra (giorni)",
"expiryHint": "Al massimo {max} giorni.",
"expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.",
- "fhirApi": "Consenti API FHIR limitata",
- "fhirApiHint": "Lascia che il link offra un endpoint FHIR di sola lettura oltre alla vista della pagina.",
- "resourceTypes": "Tipi di risorsa FHIR",
"create": "Crea link",
"tokenCreated": "Link di condivisione creato",
"copied": "Copiato negli appunti.",
diff --git a/messages/pl.json b/messages/pl.json
index 9d18cea87..48821edd1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5319,6 +5319,7 @@
"glucose": "Glukoza we krwi",
"medications": "Leki i przestrzeganie zaleceń",
"mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)",
+ "mentalHealthScreeners": "Przesiewowe testy zdrowia psychicznego (PHQ-9, GAD-7, WHO-5, SCI — domyślnie wyłączone)",
"bmi": "BMI",
"includeCharts": "Osadź wykresy trendów (tylko PDF)",
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
@@ -5342,9 +5343,6 @@
"expiry": "Wygasa za (dni)",
"expiryHint": "Najwyżej {max} dni.",
"expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.",
- "fhirApi": "Zezwól na ograniczone API FHIR",
- "fhirApiHint": "Pozwól, aby link udostępniał punkt końcowy FHIR tylko do odczytu obok widoku strony.",
- "resourceTypes": "Typy zasobów FHIR",
"create": "Utwórz link",
"tokenCreated": "Link udostępniania utworzony",
"copied": "Skopiowano do schowka.",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3b31a9a02..2aad4be6a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -5350,8 +5350,8 @@ model PushAttempt {
// `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is
// returned to the owner exactly once at creation and never persisted.
//
-// The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and
-// `resourceTypes` are write-once and a share can NEVER widen later — only
+// The scope is FROZEN at creation: `rangeStart` and `sectionsJson` are
+// write-once and a share can NEVER widen later — only
// `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This
// mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is
// mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no
@@ -5380,10 +5380,15 @@ model ClinicianShareLink {
rangeEnd DateTime? @map("range_end")
/// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON.
sectionsJson Json @map("sections_json")
- /// FHIR resource types this link may serve (a subset of the catalogue),
- /// e.g. `["Observation","MedicationStatement"]`.
+ /// RETIRED. These two columns described a scoped FHIR face for a
+ /// share link that was never built: no route ever served FHIR (or any
+ /// machine-readable format) against an `hls_` token, so the create form's
+ /// toggle, its resource-type picker, the list badge and the API description
+ /// all promised reachability that did not exist. The controls are gone, the
+ /// create path no longer writes anything but the defaults, and no read path
+ /// touches either column — the values still on rows minted earlier are inert.
+ /// Kept as columns so no data is destroyed to undo a promise.
resourceTypes String[] @map("resource_types")
- /// Whether the FHIR API is reachable via this link at all (vs the view only).
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index adde3d743..de9f67fe2 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -150,14 +150,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.25 (W-RECORDS) — structured records are always-available reference
// data (not time-windowed), so they ride alongside the report rather
// than through `collectDoctorReportData`. Owner-scoped, live rows only.
- prisma.allergy.findMany({
- where: { userId: user.id, deletedAt: null },
- orderBy: { createdAt: "desc" },
- }),
- prisma.familyHistoryEntry.findMany({
- where: { userId: user.id, deletedAt: null },
- orderBy: { createdAt: "desc" },
- }),
+ //
+ // Gated on the section toggles. These two reads fed ONLY the
+ // FHIR bundle, and they ran unconditionally: `sections.allergies = false`
+ // produced a PDF without an allergy section and a bundle carrying every
+ // AllergyIntolerance, and in the `package` format both shipped in the
+ // same zip. Zero DB read when off, matching how the aggregator treats
+ // mood — deselected data should not be loaded, not merely dropped later.
+ sections.allergies
+ ? prisma.allergy.findMany({
+ where: { userId: user.id, deletedAt: null },
+ orderBy: { createdAt: "desc" },
+ })
+ : Promise.resolve([]),
+ sections.familyHistory
+ ? prisma.familyHistoryEntry.findMany({
+ where: { userId: user.id, deletedAt: null },
+ orderBy: { createdAt: "desc" },
+ })
+ : Promise.resolve([]),
]);
// FHIR records folded into every bundle format (fhir + package).
diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts
index b92a22e33..8f8fb22f3 100644
--- a/src/app/api/share-links/route.ts
+++ b/src/app/api/share-links/route.ts
@@ -65,8 +65,6 @@ const shareLinkSummarySelect = {
label: true,
rangeStart: true,
rangeEnd: true,
- resourceTypes: true,
- allowFhirApi: true,
documentOnly: true,
passphraseHash: true,
expiresAt: true,
@@ -83,8 +81,6 @@ function toSummary(row: {
label: string;
rangeStart: Date;
rangeEnd: Date | null;
- resourceTypes: string[];
- allowFhirApi: boolean;
documentOnly: boolean;
passphraseHash: string | null;
expiresAt: Date;
@@ -99,8 +95,6 @@ function toSummary(row: {
label: row.label,
rangeStart: row.rangeStart.toISOString(),
rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null,
- resourceTypes: row.resourceTypes,
- allowFhirApi: row.allowFhirApi,
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
// gates on the column, not on "are all sections off?").
documentOnly: row.documentOnly,
@@ -141,17 +135,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
const input = parsed.data;
// v1.28.13 — a documents-only share carries NO report scope. Force the scope
- // columns to empty here (not just at the client) so a document link can never
- // serve a single health metric, whatever the body tried to smuggle: no
- // sections (an explicit all-OFF prefs blob, distinct from the `{}` that means
- // "full record defaults"), no FHIR resource types, FHIR API off. "Share this
- // document" means the document, not the whole record.
+ // column to empty here (not just at the client) so a document link can never
+ // serve a single health metric, whatever the body tried to smuggle: an
+ // explicit all-OFF prefs blob, distinct from the `{}` that means "full record
+ // defaults". "Share this document" means the document, not the whole record.
const documentOnly = input.documentOnly === true;
const sectionsJson = documentOnly
? EMPTY_DOCTOR_REPORT_PREFS
: (input.sections ?? {});
- const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []);
- const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false);
annotate({ meta: { documentOnly } });
// Mint the 192-bit raw token, store only its HMAC hash.
@@ -196,8 +187,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
rangeStart: new Date(input.rangeStart),
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
sectionsJson: sectionsJson as Prisma.InputJsonValue,
- resourceTypes,
- allowFhirApi,
documentOnly,
expiresAt: new Date(input.expiresAt),
documents:
@@ -213,8 +202,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: {
shareLinkId: created.id,
- resourceTypes: created.resourceTypes,
- allowFhirApi: created.allowFhirApi,
documentCount: created._count.documents,
expiresAt: created.expiresAt.toISOString(),
},
diff --git a/src/components/settings/health-record-export-controls.ts b/src/components/settings/health-record-export-controls.ts
new file mode 100644
index 000000000..e3c8721ff
--- /dev/null
+++ b/src/components/settings/health-record-export-controls.ts
@@ -0,0 +1,102 @@
+/**
+ * The data-selection controls of the health-record export panel: the state
+ * shape, its starting values, and the wire selection they build.
+ *
+ * Split out of the panel component so the selection contract is testable and
+ * extendable without the client component around it. The panel owns layout and
+ * interaction; this module owns "which controls exist and what each one sends",
+ * which is the part the export route, the aggregator and the control-gating
+ * guard all have to agree with — and the part the grouped-selection work will
+ * replace with a grouped registry, one file, without touching the rendering.
+ */
+/**
+ * Every data-selection control this panel renders, as one flat state shape.
+ *
+ * The shape is load-bearing beyond the component: the control-gating
+ * guard (`src/__tests__/doctor-report-control-gating-guard.test.ts`) walks these
+ * keys, folds each one through {@link buildSelectionSections} and
+ * `toDoctorReportPrefs`, and fails the build unless flipping it changes the
+ * resolved selection the aggregator consumes. Adding a control here without a
+ * gating path behind it is a build error, which is how eight controls that
+ * changed nothing stopped being possible.
+ */
+export interface SectionState {
+ weight: boolean;
+ bp: boolean;
+ pulse: boolean;
+ oxygenSaturation: boolean;
+ bodyFat: boolean;
+ bodyComposition: boolean;
+ restingHeartRate: boolean;
+ hrv: boolean;
+ vo2max: boolean;
+ steps: boolean;
+ distance: boolean;
+ sleep: boolean;
+ glucose: boolean;
+ medList: boolean;
+ compliance: boolean;
+ mood: boolean;
+ bmi: boolean;
+ labs: boolean;
+ allergies: boolean;
+ familyHistory: boolean;
+ mentalHealthScreeners: boolean;
+}
+
+export const DEFAULT_SECTIONS: SectionState = {
+ weight: true,
+ bp: true,
+ pulse: true,
+ oxygenSaturation: true,
+ bodyFat: true,
+ bodyComposition: false,
+ restingHeartRate: false,
+ hrv: false,
+ vo2max: false,
+ steps: false,
+ distance: false,
+ sleep: false,
+ glucose: true,
+ medList: true,
+ compliance: true,
+ mood: false, // privacy default
+ bmi: true,
+ labs: true,
+ allergies: true,
+ familyHistory: true,
+ // Depression / anxiety / wellbeing / insomnia screening totals — opt-in,
+ // same stance as mood.
+ mentalHealthScreeners: false,
+};
+
+export function buildSelectionSections(s: SectionState) {
+ return {
+ vitals: {
+ weight: s.weight,
+ bp: s.bp,
+ pulse: s.pulse,
+ oxygenSaturation: s.oxygenSaturation,
+ bodyFat: s.bodyFat,
+ bodyComposition: s.bodyComposition,
+ },
+ cardioFitness: {
+ restingHeartRate: s.restingHeartRate,
+ hrv: s.hrv,
+ vo2max: s.vo2max,
+ },
+ activity: {
+ steps: s.steps,
+ distance: s.distance,
+ sleep: s.sleep,
+ },
+ glucose: s.glucose,
+ medications: { list: s.medList, compliance: s.compliance },
+ mood: s.mood,
+ bmi: s.bmi,
+ labs: s.labs,
+ allergies: s.allergies,
+ familyHistory: s.familyHistory,
+ mentalHealthScreeners: s.mentalHealthScreeners,
+ };
+}
diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx
index 70bc7d956..9b16222f1 100644
--- a/src/components/settings/health-record-export-panel.tsx
+++ b/src/components/settings/health-record-export-panel.tsx
@@ -44,87 +44,16 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
+import {
+ DEFAULT_SECTIONS,
+ buildSelectionSections,
+ type SectionState,
+} from "@/components/settings/health-record-export-controls";
type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
-interface SectionState {
- weight: boolean;
- bp: boolean;
- pulse: boolean;
- oxygenSaturation: boolean;
- bodyFat: boolean;
- bodyComposition: boolean;
- restingHeartRate: boolean;
- hrv: boolean;
- vo2max: boolean;
- steps: boolean;
- distance: boolean;
- sleep: boolean;
- glucose: boolean;
- medList: boolean;
- compliance: boolean;
- mood: boolean;
- bmi: boolean;
- labs: boolean;
- allergies: boolean;
- familyHistory: boolean;
-}
-
-const DEFAULT_SECTIONS: SectionState = {
- weight: true,
- bp: true,
- pulse: true,
- oxygenSaturation: true,
- bodyFat: true,
- bodyComposition: false,
- restingHeartRate: false,
- hrv: false,
- vo2max: false,
- steps: false,
- distance: false,
- sleep: false,
- glucose: true,
- medList: true,
- compliance: true,
- mood: false, // privacy default
- bmi: true,
- labs: true,
- allergies: true,
- familyHistory: true,
-};
-
-function buildSelectionSections(s: SectionState) {
- return {
- vitals: {
- weight: s.weight,
- bp: s.bp,
- pulse: s.pulse,
- oxygenSaturation: s.oxygenSaturation,
- bodyFat: s.bodyFat,
- bodyComposition: s.bodyComposition,
- },
- cardioFitness: {
- restingHeartRate: s.restingHeartRate,
- hrv: s.hrv,
- vo2max: s.vo2max,
- },
- activity: {
- steps: s.steps,
- distance: s.distance,
- sleep: s.sleep,
- },
- glucose: s.glucose,
- medications: { list: s.medList, compliance: s.compliance },
- mood: s.mood,
- bmi: s.bmi,
- labs: s.labs,
- allergies: s.allergies,
- familyHistory: s.familyHistory,
- };
-}
-
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
@@ -171,7 +100,10 @@ export function HealthRecordExportPanel() {
// The single "Medikamente" control gates the whole medication block
// (list + compliance) so unchecking it actually excludes all medication
- // data from the export — matching the iOS five-section model.
+ // data from the export — matching the iOS five-section model. Until
+ // that was true of the compliance figures only until this change: the list
+ // names, doses and schedules, and the per-dose ledger behind it, went out
+ // regardless of the switch.
function toggleMedications() {
setSections((prev) => {
const next = !(prev.medList && prev.compliance);
@@ -451,6 +383,11 @@ export function HealthRecordExportPanel() {
checked={sections.mood}
onToggle={() => toggle("mood")}
/>
+ toggle("mentalHealthScreeners")}
+ />
`, optionally
- * exposing a scoped read-only FHIR face and a hand-picked, frozen-at-create
- * document set.
+ * read-only view of the owner's own record at `/c/`, plus a hand-picked,
+ * frozen-at-create document set. It serves a rendered page and those documents
+ * and nothing else. A FHIR toggle and a resource-type picker were removed
+ * that configured an endpoint no route ever served.
*
* The raw `hls_` token, the passphrase, and the `#k=` QR are returned EXACTLY
* ONCE on create (the server stores only hashes); this component is the single
@@ -25,10 +26,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Switch } from "@/components/ui/switch";
import {
ShareDocumentPicker,
type PickedDocument,
@@ -37,15 +36,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
-/** The FHIR resource types a share link may serve — mirrors C4's enum. */
-const RESOURCE_TYPES = [
- "Patient",
- "Observation",
- "MedicationStatement",
- "MedicationAdministration",
-] as const;
-type ResourceType = (typeof RESOURCE_TYPES)[number];
-
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
export const MAX_DAYS = 90;
const DEFAULT_DAYS = 30;
@@ -64,8 +54,6 @@ export interface ShareLinkSummary {
label: string;
rangeStart: string;
rangeEnd: string | null;
- resourceTypes: string[];
- allowFhirApi: boolean;
/** v1.18.7 — whether a passphrase second factor guards this link. */
protected: boolean;
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
@@ -99,8 +87,6 @@ export interface ShareLinkCreatePayload {
label: string;
rangeStart: string;
rangeEnd: null;
- resourceTypes: ResourceType[];
- allowFhirApi: boolean;
documentIds?: string[];
documentOnly?: boolean;
expiresAt: string;
@@ -111,8 +97,8 @@ export interface ShareLinkCreatePayload {
* function so the two surfaces' scope contract is unit-testable without a DOM.
*
* The `documentOnly` branch is the privacy-load-bearing one: a document-launched
- * share posts an EMPTY report scope (`resourceTypes: []`, no `sections`, FHIR
- * off) plus `documentOnly: true`, and ALWAYS carries the picked document ids —
+ * share posts an EMPTY report scope (no `sections`) plus `documentOnly: true`,
+ * and ALWAYS carries the picked document ids —
* so the created link serves the document(s) and nothing else. The record share
* (Settings → Sharing) keeps its full scope and only attaches documents when
* the owner picked some.
@@ -121,8 +107,6 @@ export function buildShareLinkCreatePayload(input: {
label: string;
rangeDays: number;
expiryDays: number;
- allowFhirApi: boolean;
- resourceTypes: ResourceType[];
documentIds: string[];
documentOnly: boolean;
}): ShareLinkCreatePayload {
@@ -135,8 +119,6 @@ export function buildShareLinkCreatePayload(input: {
if (input.documentOnly) {
return {
...base,
- resourceTypes: [],
- allowFhirApi: false,
documentOnly: true,
// The launched document(s) always ride along — a documents-only share
// with no document would be meaningless (the server 422s it).
@@ -145,8 +127,6 @@ export function buildShareLinkCreatePayload(input: {
}
return {
...base,
- resourceTypes: input.resourceTypes,
- allowFhirApi: input.allowFhirApi,
// Omit the key entirely when nothing is attached (a documents-less record
// share stays the default). The server re-validates each id as the caller's
// own live document before minting the link.
@@ -162,10 +142,9 @@ export function ShareLinkCreateForm({
}: {
/**
* Documents-only mode (the document-launched flow). Hides the record-scope
- * controls — FHIR API toggle, FHIR resource-type checkboxes, and the
- * history-range selector — and posts an empty report scope so the minted
- * link serves ONLY the attached document(s). Keeps the document picker,
- * label, expiry, and one-time passphrase reveal.
+ * control — the history-range selector — and posts an empty report scope so
+ * the minted link serves ONLY the attached document(s). Keeps the document
+ * picker, label, expiry, and one-time passphrase reveal.
*/
documentOnly?: boolean;
/** Documents to pre-attach — the document-launched flow seeds the one doc. */
@@ -181,11 +160,6 @@ export function ShareLinkCreateForm({
const [label, setLabel] = useState(initialLabel ?? "");
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
- const [allowFhirApi, setAllowFhirApi] = useState(false);
- const [resourceTypes, setResourceTypes] = useState([
- "Patient",
- "Observation",
- ]);
const [selectedDocs, setSelectedDocs] = useState(
initialDocuments ?? [],
);
@@ -238,8 +212,6 @@ export function ShareLinkCreateForm({
label,
rangeDays,
expiryDays,
- allowFhirApi,
- resourceTypes,
documentIds: selectedDocs.map((d) => d.id),
documentOnly,
}),
@@ -268,12 +240,6 @@ export function ShareLinkCreateForm({
},
});
- function toggleResourceType(type: ResourceType) {
- setResourceTypes((prev) =>
- prev.includes(type) ? prev.filter((r) => r !== type) : [...prev, type],
- );
- }
-
async function copyValue(value: string, which: "link" | "passphrase") {
try {
await navigator.clipboard.writeText(value);
@@ -344,51 +310,6 @@ export function ShareLinkCreateForm({
- {/* The FHIR API + its resource-type scope expose the health RECORD.
- A documents-only link serves no record, so both are hidden and the
- posted scope is forced empty server-side regardless. */}
- {!documentOnly ? (
-
diff --git a/src/lib/clinician-share/resolve-share-token.ts b/src/lib/clinician-share/resolve-share-token.ts
index 53ca7ff3a..d539ffd7e 100644
--- a/src/lib/clinician-share/resolve-share-token.ts
+++ b/src/lib/clinician-share/resolve-share-token.ts
@@ -48,10 +48,6 @@ export interface ShareContext {
rangeEnd: Date | null;
/** Frozen section toggles (the `DoctorReportPrefs` JSON shape). */
sectionsJson: unknown;
- /** FHIR resource types this link may serve (a subset of the catalogue). */
- resourceTypes: string[];
- /** Whether the scoped FHIR API is reachable via this link at all. */
- allowFhirApi: boolean;
/**
* v1.28.16 — authoritative documents-only flag, frozen at create. When true
* the view loader serves ONLY documents and never aggregates a health report,
@@ -89,8 +85,6 @@ export async function resolveShareToken(
rangeStart: true,
rangeEnd: true,
sectionsJson: true,
- resourceTypes: true,
- allowFhirApi: true,
documentOnly: true,
expiresAt: true,
revokedAt: true,
@@ -120,8 +114,6 @@ export async function resolveShareToken(
rangeStart: row.rangeStart,
rangeEnd: row.rangeEnd,
sectionsJson: row.sectionsJson,
- resourceTypes: row.resourceTypes,
- allowFhirApi: row.allowFhirApi,
documentOnly: row.documentOnly,
expiresAt: row.expiresAt,
};
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index ec5bc55d7..f5085a054 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -41,7 +41,11 @@ import {
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
-import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
+import { resolveModuleMap } from "@/lib/modules/gate";
+import {
+ excludedMeasurementTypes as excludedMeasurementTypesForSelection,
+ filterMeasurementKeys,
+} from "@/lib/doctor-report-selection";
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import {
WELLNESS_SCORE_REPORT_TYPES,
@@ -448,38 +452,16 @@ export async function collectDoctorReportData(
const recoveryEnabled = moduleMap.recovery !== false;
const workoutsEnabled = moduleMap.workouts !== false;
- // v1.28.25 — push the section / module gates INTO the measurement query.
- // `filterMeasurementKeys` below strips gated types from the returned
- // payload anyway, so rows of a disabled section / module were fetched only
- // to be dropped — pure dead weight, and the heaviest series (BLOOD_GLUCOSE
- // minute-grain CGM, gated by the glucose module) is exactly the one that
- // runs to six figures over a 730-day window. Excluding them at the query
- // is output-identical by construction.
- //
- // WEIGHT is deliberately NEVER excluded: the BMI figure (gated by
- // `sections.bmi`, not `sections.weight`) and the GLP-1 weight-delta both
- // read the PRE-filter `byType` / `stats` maps, so weight rows must be
- // present even when the weight section itself is toggled off. No other
- // gated type is read before the filter (each remaining pre-filter read —
- // sleep nights, glucose panel, recovery summary — sits behind the same
- // gate that drives its exclusion here).
- //
- // Every sparse type keeps the raw-row path. On long windows, the two
- // sample-dense types move to one SQL row per local day/source/device/context;
- // the canonical-source picker then works over that bounded intermediate set.
- const excludedMeasurementTypes: MeasurementType[] = [];
- if (sections.bp === false) {
- excludedMeasurementTypes.push("BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA");
- }
- if (sections.pulse === false) excludedMeasurementTypes.push("PULSE");
- if (sections.sleep === false) excludedMeasurementTypes.push("SLEEP_DURATION");
- if (sections.glucose === false)
- excludedMeasurementTypes.push("BLOOD_GLUCOSE");
- for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) {
- if (moduleMap[moduleKey] === false) {
- excludedMeasurementTypes.push(type as MeasurementType);
- }
- }
+ // v1.28.25 — the section / module gates are pushed INTO the measurement
+ // query (see `doctor-report-selection.ts` for why, and for the one type the
+ // query must keep regardless). Every sparse type keeps the raw-row path; on
+ // long windows the two sample-dense types move to one SQL row per local
+ // day/source/device/context, and the canonical-source picker then works over
+ // that bounded intermediate set.
+ const excludedMeasurementTypes = excludedMeasurementTypesForSelection(
+ sections,
+ moduleMap,
+ );
const userProfile = await prisma.user.findUnique({
where: { id: userId },
@@ -1403,22 +1385,37 @@ export async function collectDoctorReportData(
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: filteredBmi,
compliance: filteredCompliance,
- medications: medications.map((m) => ({
- name: m.name,
- dose: m.dose,
- // v1.9.0 — drug-classification codes for the coded FHIR concept.
- atcCode: m.atcCode,
- rxNormCode: m.rxNormCode,
- schedules: m.schedules.map((s) => ({
- windowStart: s.windowStart,
- windowEnd: s.windowEnd,
- label: s.label,
- })),
- })),
- medicationAdministrations: cappedAdministrations,
- medicationAdministrationsTruncation: medicationAdministrationsTruncated
- ? { total: totalAdministrations, included: cappedAdministrations.length }
- : null,
+ // The medication list and the per-dose administration ledger now
+ // ride `sections.medicationList`. Both were exported unconditionally while
+ // the panel's single medication control claimed in its own comment to
+ // exclude all medication data, so a user who switched medications off still
+ // handed over every drug name, dose, schedule, injection site and skipped
+ // dose in the FHIR bundle. The rows are still READ (compliance and the
+ // GLP-1 block are computed from them); they no longer leave the function.
+ medications: sections.medicationList
+ ? medications.map((m) => ({
+ name: m.name,
+ dose: m.dose,
+ // v1.9.0 — drug-classification codes for the coded FHIR concept.
+ atcCode: m.atcCode,
+ rxNormCode: m.rxNormCode,
+ schedules: m.schedules.map((s) => ({
+ windowStart: s.windowStart,
+ windowEnd: s.windowEnd,
+ label: s.label,
+ })),
+ }))
+ : [],
+ medicationAdministrations: sections.medicationList
+ ? cappedAdministrations
+ : [],
+ medicationAdministrationsTruncation:
+ sections.medicationList && medicationAdministrationsTruncated
+ ? {
+ total: totalAdministrations,
+ included: cappedAdministrations.length,
+ }
+ : null,
mood,
glp1,
wellnessScores,
@@ -1429,82 +1426,3 @@ export async function collectDoctorReportData(
familyHistory,
};
}
-
-/**
- * Per-measurement-type → section-toggle mapping. Keys not listed here
- * (e.g., body fat, oxygen saturation) bypass the toggles — those sections
- * have no per-report flag. Glucose DOES carry a `glucose` section toggle,
- * but `BLOOD_GLUCOSE` is gated at query-exclusion time (mirroring sleep) and
- * its panel collapses via `glucoseEnabled`, so it does not ride this map.
- */
-const MEASUREMENT_TYPE_SECTION: Record = {
- BLOOD_PRESSURE_SYS: "bp",
- BLOOD_PRESSURE_DIA: "bp",
- WEIGHT: "weight",
- PULSE: "pulse",
- SLEEP_DURATION: "sleep",
-};
-
-/**
- * v1.18.0 — per-measurement-type → owning module mapping for the types that
- * carry no `sections` toggle but DO belong to a toggleable module. When the
- * module is disabled, the type is stripped from `measurements` + `stats` so the
- * series never reaches the PDF tables or a FHIR Observation.
- *
- * - `workouts` owns the activity / movement series (steps, active energy,
- * distance, flights, the walking-gait + stair metrics) and the
- * training-load `STRAIN_SCORE`.
- * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`.
- * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is
- * gated where it is assembled; this strips the raw rows too).
- *
- * Core clinical types (BP / weight / pulse / body-composition / vitals) carry
- * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride
- * the `sections` mechanism instead.
- */
-const MEASUREMENT_TYPE_MODULE: Record = {
- ACTIVITY_STEPS: "workouts",
- ACTIVE_ENERGY_BURNED: "workouts",
- FLIGHTS_CLIMBED: "workouts",
- WALKING_RUNNING_DISTANCE: "workouts",
- WALKING_SPEED: "workouts",
- WALKING_ASYMMETRY: "workouts",
- WALKING_STEP_LENGTH: "workouts",
- WALKING_DOUBLE_SUPPORT: "workouts",
- SIX_MINUTE_WALK_DISTANCE: "workouts",
- STAIR_ASCENT_SPEED: "workouts",
- STAIR_DESCENT_SPEED: "workouts",
- STRAIN_SCORE: "workouts",
- RECOVERY_SCORE: "recovery",
- STRESS_SCORE: "recovery",
- BLOOD_GLUCOSE: "glucose",
- // v1.25.0 — the PHQ-9 / GAD-7 screener TOTALS ride the doctor-report / FHIR
- // export only when the opt-in mental-health module is on (default OFF →
- // excluded by default, privacy-by-default). Item-level answers are never a
- // Measurement, so they can never leak through this path regardless. The total
- // is the intended clinical artefact (total + band per clinical-instruments.md
- // §4); gating it on the module keeps the export consistent with how mood /
- // sleep / glucose are gated and avoids exporting a screener the account never
- // opted into.
- PHQ9_SCORE: "mentalHealth",
- GAD7_SCORE: "mentalHealth",
- // v1.27.9 — the WHO-5 / SCI totals ride the same module gate.
- WHO5_SCORE: "mentalHealth",
- SCI_SCORE: "mentalHealth",
-};
-
-function filterMeasurementKeys(
- map: Record,
- sections: DoctorReportPrefs,
- moduleMap: Record,
-): Record {
- const out: Record = {};
- for (const [type, value] of Object.entries(map)) {
- const sectionKey = MEASUREMENT_TYPE_SECTION[type];
- if (sectionKey && sections[sectionKey] === false) continue;
- const moduleKey = MEASUREMENT_TYPE_MODULE[type];
- if (moduleKey && moduleMap[moduleKey] === false) continue;
- out[type] = value;
- }
- return out;
-}
diff --git a/src/lib/doctor-report-selection.ts b/src/lib/doctor-report-selection.ts
new file mode 100644
index 000000000..5a18a2d9c
--- /dev/null
+++ b/src/lib/doctor-report-selection.ts
@@ -0,0 +1,177 @@
+/**
+ * Which measurement types a doctor-report selection admits.
+ *
+ * Extracted from `doctor-report-data.ts` alongside the gating fix that made
+ * the export panel's controls real. The aggregator is a long file that reads
+ * a dozen data domains; the question "does this selection admit this
+ * measurement type?" is a separate job with a separate shape, it is the one
+ * the export panel's controls resolve to, and it is the piece the grouped
+ * selection work will grow. It answers to two independent gates, and both
+ * must pass:
+ *
+ * - the per-report SECTION toggle the user chose for this artefact, and
+ * - the app-wide MODULE switch that decides whether the domain exists here
+ * at all.
+ *
+ * Both maps and the filter that reads them live together so a type can never
+ * be gated in the query and ungated in the output, or the reverse.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import type { ModuleKey } from "@/lib/modules/gate";
+import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
+
+/**
+ * Per-measurement-type → section-toggle mapping. This is the one enforcement
+ * point for "the user deselected this data": `filterMeasurementKeys` strips a
+ * gated type from `measurements` + `stats`, and PDF, FHIR bundle, package,
+ * clinician share view and MCP all read those two maps — so one entry here
+ * gates every format at once.
+ *
+ * The map used to hold five types, which is why the export panel
+ * could render controls for SpO₂, body fat, body composition, resting heart
+ * rate, HRV, VO₂max, steps and distance and change nothing at all. Every
+ * control the panel renders now resolves to a section key that appears here (or
+ * to a structured section gated by name in the aggregator body), and the
+ * control-gating guard test fails the build if that stops being true.
+ *
+ * Glucose DOES carry a `glucose` section toggle, but `BLOOD_GLUCOSE` is gated
+ * at query-exclusion time (mirroring sleep) and its panel collapses via
+ * `glucoseEnabled`, so it does not ride this map.
+ */
+export const MEASUREMENT_TYPE_SECTION: Record =
+ {
+ BLOOD_PRESSURE_SYS: "bp",
+ BLOOD_PRESSURE_DIA: "bp",
+ WEIGHT: "weight",
+ PULSE: "pulse",
+ SLEEP_DURATION: "sleep",
+ OXYGEN_SATURATION: "oxygenSaturation",
+ BODY_FAT: "bodyFat",
+ // The scale-derived composition family: everything a body-composition scale
+ // reports beyond the plain weight and fat percentage.
+ TOTAL_BODY_WATER: "bodyComposition",
+ BONE_MASS: "bodyComposition",
+ FAT_MASS: "bodyComposition",
+ FAT_FREE_MASS: "bodyComposition",
+ MUSCLE_MASS: "bodyComposition",
+ LEAN_BODY_MASS: "bodyComposition",
+ VISCERAL_FAT: "bodyComposition",
+ RESTING_HEART_RATE: "restingHeartRate",
+ // Both HRV estimators ride one control: SDNN and RMSSD are the same signal to
+ // a reader deciding whether to hand over heart-rate variability at all.
+ HEART_RATE_VARIABILITY: "hrv",
+ HRV_RMSSD: "hrv",
+ VO2_MAX: "vo2max",
+ ACTIVITY_STEPS: "steps",
+ WALKING_RUNNING_DISTANCE: "distance",
+ // Screening totals. The `mentalHealth` module gate below still applies; this
+ // is the per-artefact choice that gate stopped standing in for.
+ PHQ9_SCORE: "mentalHealthScreeners",
+ GAD7_SCORE: "mentalHealthScreeners",
+ WHO5_SCORE: "mentalHealthScreeners",
+ SCI_SCORE: "mentalHealthScreeners",
+ };
+
+/**
+ * v1.18.0 — per-measurement-type → owning module mapping for the types that
+ * carry no `sections` toggle but DO belong to a toggleable module. When the
+ * module is disabled, the type is stripped from `measurements` + `stats` so the
+ * series never reaches the PDF tables or a FHIR Observation.
+ *
+ * - `workouts` owns the activity / movement series (steps, active energy,
+ * distance, flights, the walking-gait + stair metrics) and the
+ * training-load `STRAIN_SCORE`.
+ * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`.
+ * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is
+ * gated where it is assembled; this strips the raw rows too).
+ *
+ * Core clinical types (BP / weight / pulse / body-composition / vitals) carry
+ * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride
+ * the `sections` mechanism instead.
+ */
+export const MEASUREMENT_TYPE_MODULE: Record = {
+ ACTIVITY_STEPS: "workouts",
+ ACTIVE_ENERGY_BURNED: "workouts",
+ FLIGHTS_CLIMBED: "workouts",
+ WALKING_RUNNING_DISTANCE: "workouts",
+ WALKING_SPEED: "workouts",
+ WALKING_ASYMMETRY: "workouts",
+ WALKING_STEP_LENGTH: "workouts",
+ WALKING_DOUBLE_SUPPORT: "workouts",
+ SIX_MINUTE_WALK_DISTANCE: "workouts",
+ STAIR_ASCENT_SPEED: "workouts",
+ STAIR_DESCENT_SPEED: "workouts",
+ STRAIN_SCORE: "workouts",
+ RECOVERY_SCORE: "recovery",
+ STRESS_SCORE: "recovery",
+ BLOOD_GLUCOSE: "glucose",
+ // The PHQ-9 / GAD-7 / WHO-5 / SCI screener TOTALS ride the mental-health
+ // module as well as the `mentalHealthScreeners` section key above. Item-level
+ // answers are never a Measurement, so they can never leak through this path
+ // regardless; the total is the intended clinical artefact (total + band per
+ // clinical-instruments.md §4).
+ //
+ // The module gate used to be the ONLY gate, on the argument that
+ // the module was opt-in and therefore default-OFF. It has been default-ON
+ // since v1.29.1, so that argument stopped holding and depression / anxiety
+ // screening totals reached FHIR bundles, the MCP doctor-visit resource and
+ // clinician share links on accounts that never chose to send them. The
+ // section key restores the choice; the module gate stays as the second lock.
+ PHQ9_SCORE: "mentalHealth",
+ GAD7_SCORE: "mentalHealth",
+ WHO5_SCORE: "mentalHealth",
+ SCI_SCORE: "mentalHealth",
+};
+
+export function filterMeasurementKeys(
+ map: Record,
+ sections: DoctorReportPrefs,
+ moduleMap: Record,
+): Record {
+ const out: Record = {};
+ for (const [type, value] of Object.entries(map)) {
+ const sectionKey = MEASUREMENT_TYPE_SECTION[type];
+ if (sectionKey && sections[sectionKey] === false) continue;
+ const moduleKey = MEASUREMENT_TYPE_MODULE[type];
+ if (moduleKey && moduleMap[moduleKey] === false) continue;
+ out[type] = value;
+ }
+ return out;
+}
+
+/**
+ * The measurement types to exclude at QUERY time for a resolved selection.
+ *
+ * v1.28.25 pushed the gates into the SQL because rows of a deselected section
+ * were fetched only to be dropped by {@link filterMeasurementKeys} — pure dead
+ * weight, and the heaviest series (minute-grain CGM) is exactly the one that
+ * runs to six figures over a long window. This is output-identical by
+ * construction because it reads the same two maps the filter reads.
+ *
+ * WEIGHT is deliberately NEVER excluded: the BMI figure (gated by
+ * `sections.bmi`, not `sections.weight`) and the GLP-1 weight delta both read
+ * the PRE-filter maps, so weight rows must be present even when the weight
+ * section itself is off. No other gated type is read before the filter — each
+ * remaining pre-filter read (sleep nights, the glucose panel, the recovery
+ * summary) sits behind the same gate that drives its exclusion here.
+ */
+export function excludedMeasurementTypes(
+ sections: DoctorReportPrefs,
+ moduleMap: Record,
+): MeasurementType[] {
+ const excluded: MeasurementType[] = [];
+ for (const [type, sectionKey] of Object.entries(MEASUREMENT_TYPE_SECTION)) {
+ if (type === WEIGHT_PREFILTER_EXCEPTION) continue;
+ if (sections[sectionKey] === false) excluded.push(type as MeasurementType);
+ }
+ // Glucose carries a section toggle but no entry in the type→section map: the
+ // raw series is gated here and the panel it feeds collapses separately.
+ if (sections.glucose === false) excluded.push("BLOOD_GLUCOSE");
+ for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) {
+ if (moduleMap[moduleKey] === false) excluded.push(type as MeasurementType);
+ }
+ return excluded;
+}
+
+/** The one type the query gate must keep even when its section is off. */
+const WEIGHT_PREFILTER_EXCEPTION = "WEIGHT";
diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts
index 4a3b04e38..4f8c24426 100644
--- a/src/lib/openapi/routes/coach.ts
+++ b/src/lib/openapi/routes/coach.ts
@@ -231,7 +231,7 @@ const insightsLayoutResult = insightsLayoutSchema
createShareLinkSchema.meta({
id: "CreateShareLinkRequest",
description:
- "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. Strict: unknown keys 422.",
+ "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `sections` freezes which record domains the link may serve. A share link serves the rendered page and the documents frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys 422.",
});
exportSelectionSchema.meta({
diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts
index 57137b7b8..4e118066d 100644
--- a/src/lib/openapi/routes/health-record.ts
+++ b/src/lib/openapi/routes/health-record.ts
@@ -20,8 +20,6 @@ const shareLinkSummary = z.object({
label: z.string(),
rangeStart: z.string(),
rangeEnd: z.string().nullable(),
- resourceTypes: z.array(z.string()),
- allowFhirApi: z.boolean(),
// v1.18.7 — whether a passphrase second factor guards this link. Always true
// for links created on v1.18.7+; false only for legacy links.
protected: z.boolean(),
diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts
index fb98b488e..6e0428acf 100644
--- a/src/lib/validations/clinician-share-link.ts
+++ b/src/lib/validations/clinician-share-link.ts
@@ -4,33 +4,26 @@
* The OWNER creates a time-boxed, scope-frozen share link to their own health
* record. On create the server mints an `hls_<48 hex>` token (192-bit), stores
* ONLY its HMAC hash, and returns the raw token exactly once. Every scope
- * column (window, sections, FHIR resource types, API toggle) is write-once at
- * creation — there is no widen/update path. `expiresAt` is REQUIRED and capped
- * at `SHARE_LINK_MAX_DAYS` so no link ever lives forever.
+ * column (window, sections) is write-once at creation — there is no
+ * widen/update path. `expiresAt` is REQUIRED and capped at
+ * `SHARE_LINK_MAX_DAYS` so no link ever lives forever.
+ *
+ * The `resourceTypes` / `allowFhirApi` keys are gone. A share link
+ * serves a rendered page and the documents frozen onto it; it has never served
+ * FHIR, and accepting a scope for a face that does not exist only made the
+ * promise look kept. An unknown key is rejected by `.strict()`, so a caller
+ * still sending them gets a 422 that names them rather than a silent no-op.
*
* Strict: `.strict()` rejects unknown keys; there is intentionally no `userId`
* field (the owner is always narrowed from `requireAuth()`).
*/
import { z } from "zod/v4";
-import { FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest";
import { exportSectionsSchema } from "@/lib/validations/health-record-export";
/** Maximum lifetime of a share link, in days. No never-expiring share. */
export const SHARE_LINK_MAX_DAYS = 90;
-/**
- * The FHIR resource types a share link may be scoped to serve — exactly the
- * read-only catalogue the REST face exposes. Derived from the single canonical
- * `FHIR_REST_RESOURCE_TYPES` so a share can never be scoped to a type the REST
- * face does not actually route. A create request may select any subset; an
- * empty array means "no FHIR resources" (view-only, moot when `allowFhirApi`
- * is off).
- */
-export const SHARE_LINK_RESOURCE_TYPES = FHIR_REST_RESOURCE_TYPES;
-
-export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);
-
const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000;
/**
@@ -56,20 +49,17 @@ export const createShareLinkSchema = z
rangeStart: z.iso.datetime({ offset: true }),
rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(),
sections: exportSectionsSchema.optional(),
- resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(),
- allowFhirApi: z.boolean().optional(),
documentIds: z
.array(z.string().trim().min(1).max(64))
.max(SHARE_LINK_MAX_DOCUMENTS)
.optional(),
/**
* v1.28.13 — a documents-only share. When true the server FORCES an empty
- * report scope (no sections, no FHIR resource types, FHIR API off) and
- * mints a link that serves ONLY the attached documents — never a health
- * metric. "Share this document" means the document, not the whole record.
- * A documents-only share MUST carry at least one `documentId` (enforced
- * below). Any `sections` / `resourceTypes` / `allowFhirApi` sent alongside
- * are ignored server-side so a document link can never widen into a record.
+ * report scope and mints a link that serves ONLY the attached documents —
+ * never a health metric. "Share this document" means the document, not the
+ * whole record. A documents-only share MUST carry at least one
+ * `documentId` (enforced below). Any `sections` sent alongside are ignored
+ * server-side so a document link can never widen into a record.
*/
documentOnly: z.boolean().optional(),
expiresAt: z.iso
diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts
index 2da3c2075..48d8c5859 100644
--- a/src/lib/validations/doctor-report-prefs.ts
+++ b/src/lib/validations/doctor-report-prefs.ts
@@ -29,6 +29,7 @@ export const doctorReportPrefsSchema = z
pulse: z.boolean(),
bmi: z.boolean(),
mood: z.boolean(),
+ medicationList: z.boolean(),
compliance: z.boolean(),
sleep: z.boolean(),
glucose: z.boolean(),
@@ -36,6 +37,15 @@ export const doctorReportPrefsSchema = z
labs: z.boolean(),
allergies: z.boolean(),
familyHistory: z.boolean(),
+ oxygenSaturation: z.boolean(),
+ bodyFat: z.boolean(),
+ bodyComposition: z.boolean(),
+ restingHeartRate: z.boolean(),
+ hrv: z.boolean(),
+ vo2max: z.boolean(),
+ steps: z.boolean(),
+ distance: z.boolean(),
+ mentalHealthScreeners: z.boolean(),
})
.partial();
@@ -52,6 +62,12 @@ export interface DoctorReportPrefs {
pulse: boolean;
bmi: boolean;
mood: boolean;
+ /**
+ * The active-medication list (names, doses, schedules) and the
+ * per-dose administration ledger. Previously exported unconditionally while
+ * the only medication control folded into {@link DoctorReportPrefs.compliance}.
+ */
+ medicationList: boolean;
compliance: boolean;
sleep: boolean;
glucose: boolean;
@@ -59,6 +75,26 @@ export interface DoctorReportPrefs {
labs: boolean;
allergies: boolean;
familyHistory: boolean;
+ /**
+ * The measurement groups the export panel has always rendered a
+ * control for but nothing gated. Each one now drives
+ * `MEASUREMENT_TYPE_SECTION` in the aggregator, so it reaches PDF, FHIR and
+ * the package alike.
+ */
+ oxygenSaturation: boolean;
+ bodyFat: boolean;
+ bodyComposition: boolean;
+ restingHeartRate: boolean;
+ hrv: boolean;
+ vo2max: boolean;
+ steps: boolean;
+ distance: boolean;
+ /**
+ * PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. Depression,
+ * anxiety, wellbeing and insomnia instruments: opt-in per artefact like
+ * mood, never carried by an absent key.
+ */
+ mentalHealthScreeners: boolean;
}
/**
@@ -73,6 +109,10 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
pulse: true,
bmi: true,
mood: false, // privacy default per the maintainer
+ // The medication list has always ridden along; the flag exists so the panel's
+ // single "medications" control can actually withhold it, not to change what a
+ // caller that omits the key receives.
+ medicationList: true,
compliance: true,
sleep: true,
// Glucose the user recorded to share with a clinician — ON by default,
@@ -93,6 +133,24 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
allergies: true,
// Structured family history — same stance as allergies.
familyHistory: true,
+ // The measurement groups that gained a real gate here. They default ON
+ // because that is what an omitted key has always produced; the point of the
+ // flags is that a caller who switches one OFF is now obeyed. The web panel
+ // sends every one of them explicitly.
+ oxygenSaturation: true,
+ bodyFat: true,
+ bodyComposition: true,
+ restingHeartRate: true,
+ hrv: true,
+ vo2max: true,
+ steps: true,
+ distance: true,
+ // Mental-health screening totals are opt-in per artefact, same stance as
+ // mood: PHQ-9 and GAD-7 are depression and anxiety instruments and must not
+ // leave the instance because a caller omitted a key. The `mentalHealth`
+ // module gate still applies on top — it just no longer stands alone, having
+ // been default-ON since v1.29.1.
+ mentalHealthScreeners: false,
};
/**
@@ -108,6 +166,7 @@ export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
pulse: false,
bmi: false,
mood: false,
+ medicationList: false,
compliance: false,
sleep: false,
glucose: false,
@@ -115,6 +174,15 @@ export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
labs: false,
allergies: false,
familyHistory: false,
+ oxygenSaturation: false,
+ bodyFat: false,
+ bodyComposition: false,
+ restingHeartRate: false,
+ hrv: false,
+ vo2max: false,
+ steps: false,
+ distance: false,
+ mentalHealthScreeners: false,
};
/**
diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts
index 37538d4d2..310cd26c2 100644
--- a/src/lib/validations/health-record-export.ts
+++ b/src/lib/validations/health-record-export.ts
@@ -45,17 +45,21 @@ const activityGroupSchema = z
.object({
steps: z.boolean(),
distance: z.boolean(),
- energy: z.boolean(),
sleep: z.boolean(),
})
.partial();
+/**
+ * `list` gates the medication list plus the per-dose administration
+ * ledger; `compliance` gates the adherence figures and the GLP-1 therapy block.
+ * The retired `glp1` / `sideEffects` / `activity.energy` keys were accepted and
+ * discarded: no surface ever rendered them and no code read them. An unknown
+ * key inside a group is stripped, so a caller still sending one is unaffected.
+ */
const medicationsGroupSchema = z
.object({
list: z.boolean(),
compliance: z.boolean(),
- glp1: z.boolean(),
- sideEffects: z.boolean(),
})
.partial();
@@ -83,6 +87,9 @@ export const exportSectionsSchema = z
allergies: z.boolean(),
// Structured family history — ON by default, same stance as allergies.
familyHistory: z.boolean(),
+ // PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. OFF unless the
+ // caller sets it explicitly (`=== true`), like mood and cycle.
+ mentalHealthScreeners: z.boolean(),
})
.partial();
@@ -172,14 +179,23 @@ export type ExportSelection = z.infer;
* Fold the grouped selection down to the flat `DoctorReportPrefs` the
* aggregator + PDF renderer consume. A group toggle that is `true` (or
* absent, falling back to the section default) flips the matching flat
- * flag. Mood stays privacy-default-OFF: it only flips on when the caller
- * explicitly set `sections.mood = true`.
+ * flag. Mood, cycle and the mental-health screeners stay privacy-default-OFF:
+ * they only flip on when the caller set them explicitly.
+ *
+ * The fold used to read twelve of the twenty keys the schema
+ * accepts and drop the rest on the floor, so a caller that switched off SpO₂,
+ * body fat, body composition, resting heart rate, HRV, VO₂max, steps or
+ * distance was silently overruled and the export panel rendered eight controls
+ * that changed nothing. Every accepted key now resolves to a flat flag, and
+ * `doctor-report-control-gating-guard.test.ts` fails the build if a control is
+ * ever added back without one.
*/
export function toDoctorReportPrefs(
sections: ExportSections | undefined,
): DoctorReportPrefs {
const s = sections ?? {};
const v = s.vitals ?? {};
+ const c = s.cardioFitness ?? {};
const a = s.activity ?? {};
const m = s.medications ?? {};
@@ -191,6 +207,10 @@ export function toDoctorReportPrefs(
bmi: s.bmi ?? fallback.bmi,
// Mood is opt-in: only true when explicitly requested.
mood: s.mood === true,
+ medicationList: m.list ?? fallback.medicationList,
+ // A caller that sends only `medications.list = false` means "no medication
+ // data" — the single panel control has always driven both keys together, so
+ // the list value keeps standing in for a missing compliance value.
compliance: m.compliance ?? m.list ?? fallback.compliance,
sleep: a.sleep ?? fallback.sleep,
glucose: s.glucose ?? fallback.glucose,
@@ -199,5 +219,15 @@ export function toDoctorReportPrefs(
labs: s.labs ?? fallback.labs,
allergies: s.allergies ?? fallback.allergies,
familyHistory: s.familyHistory ?? fallback.familyHistory,
+ oxygenSaturation: v.oxygenSaturation ?? fallback.oxygenSaturation,
+ bodyFat: v.bodyFat ?? fallback.bodyFat,
+ bodyComposition: v.bodyComposition ?? fallback.bodyComposition,
+ restingHeartRate: c.restingHeartRate ?? fallback.restingHeartRate,
+ hrv: c.hrv ?? fallback.hrv,
+ vo2max: c.vo2max ?? fallback.vo2max,
+ steps: a.steps ?? fallback.steps,
+ distance: a.distance ?? fallback.distance,
+ // Screening totals are opt-in: only true when explicitly requested.
+ mentalHealthScreeners: s.mentalHealthScreeners === true,
};
}
From f90219784b2a16cb0cea69dbe583cff75426e2a9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 17:22:48 +0200
Subject: [PATCH 02/17] test(export): pin the selection controls against going
inert again
Two layers, because the defect had two halves and each needs its own kind of
proof.
The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".
The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.
Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.
The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
---
...doctor-report-control-gating-guard.test.ts | 187 +++++++++++
.../__tests__/route.test.ts | 16 +-
.../health-record/__tests__/route.test.ts | 294 +++++++++++++++++-
.../api/share-links/__tests__/route.test.ts | 42 ++-
src/app/c/[token]/__tests__/page.test.tsx | 2 -
.../c/[token]/d/[id]/__tests__/route.test.ts | 2 -
.../__tests__/share-link-create-form.test.tsx | 22 +-
.../__tests__/sharing-section.test.tsx | 9 +-
.../__tests__/resolve-share-token.test.ts | 6 +-
.../__tests__/share-view-data.test.ts | 2 -
.../__tests__/doctor-report-prefs.test.ts | 9 +-
11 files changed, 533 insertions(+), 58 deletions(-)
create mode 100644 src/__tests__/doctor-report-control-gating-guard.test.ts
diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts
new file mode 100644
index 000000000..b7c476dff
--- /dev/null
+++ b/src/__tests__/doctor-report-control-gating-guard.test.ts
@@ -0,0 +1,187 @@
+/**
+ * Structural guard: a rendered export control with no gating path fails the
+ * build.
+ *
+ * The health-record export panel shipped twenty data-selection switches, of
+ * which eight changed nothing at all. `toDoctorReportPrefs` folded the grouped
+ * wire shape down to twelve flat flags and dropped the rest on the floor, so a
+ * user who switched off SpO₂, body fat, body composition, resting heart rate,
+ * HRV, VO₂max, steps or distance exported them anyway — in the PDF, in the FHIR
+ * bundle, in the package, in a clinician share link and over MCP. Nothing
+ * failed; the control simply lied, and it lied for several releases because no
+ * test connected "the panel renders this" to "the export honours this".
+ *
+ * This file is that connection, in three links that each break independently:
+ *
+ * 1. Every control the panel holds state for survives the wire schema. A key
+ * the schema strips can never be honoured, however carefully the fold is
+ * written.
+ * 2. Flipping exactly one control changes the resolved `DoctorReportPrefs`
+ * the aggregator consumes. This is the assertion that would have caught
+ * all eight dead switches on the day they were added.
+ * 3. Every flag in the resolved shape is read downstream — either as a
+ * per-measurement-type entry in the selection gates or by name in the
+ * aggregator body. A flag nothing reads is the same defect one layer down.
+ *
+ * It also checks the panel actually renders every control it declares, so the
+ * split between the control shape and its rendering cannot quietly leave a
+ * declared control with no row (or a row with no gate).
+ *
+ * What it does NOT prove: that the excluded data is actually absent from a
+ * rendered artefact. That is a behavioural property and it is covered where the
+ * artefacts are built — `src/app/api/export/health-record/__tests__/route.test.ts`
+ * asserts the PDF, the FHIR bundle and the zip for the same selections. This
+ * guard is the cheap structural half that runs on every commit and cannot be
+ * satisfied by writing a control that looks plausible.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import {
+ exportSectionsSchema,
+ toDoctorReportPrefs,
+} from "@/lib/validations/health-record-export";
+import {
+ DEFAULT_DOCTOR_REPORT_PREFS,
+ type DoctorReportPrefs,
+} from "@/lib/validations/doctor-report-prefs";
+import {
+ DEFAULT_SECTIONS,
+ buildSelectionSections,
+ type SectionState,
+} from "@/components/settings/health-record-export-controls";
+import { MEASUREMENT_TYPE_SECTION } from "@/lib/doctor-report-selection";
+
+const REPO_ROOT = join(__dirname, "..", "..");
+const PANEL_PATH = join(
+ REPO_ROOT,
+ "src",
+ "components",
+ "settings",
+ "health-record-export-panel.tsx",
+);
+const AGGREGATOR_PATH = join(REPO_ROOT, "src", "lib", "doctor-report-data.ts");
+
+const panelSource = readFileSync(PANEL_PATH, "utf8");
+const aggregatorSource = readFileSync(AGGREGATOR_PATH, "utf8");
+
+/** Strip comments so a key mentioned only in prose does not count as read. */
+function stripComments(source: string): string {
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
+}
+
+/**
+ * The control set, taken from the panel's own starting values rather than from
+ * a list this test maintains — a list would drift exactly the way the fold
+ * drifted. `DEFAULT_SECTIONS` is typed `SectionState`, so TypeScript already
+ * forces it to name every control, and every rendered row must bind one of
+ * them. "Declared here" and "rendered" are therefore the same set.
+ */
+const CONTROL_KEYS = Object.keys(DEFAULT_SECTIONS) as (keyof SectionState)[];
+
+/** All controls on, as the panel's state shape. */
+function allOn(): SectionState {
+ return Object.fromEntries(
+ CONTROL_KEYS.map((k) => [k, true]),
+ ) as unknown as SectionState;
+}
+
+/**
+ * The exact path a selection takes in production: panel state → grouped wire
+ * shape → the route's Zod parse (which strips anything the schema does not
+ * declare) → the flat prefs the aggregator consumes.
+ */
+function resolve(state: SectionState): DoctorReportPrefs {
+ return toDoctorReportPrefs(
+ exportSectionsSchema.parse(buildSelectionSections(state)),
+ );
+}
+
+describe("export control gating — structural guard", () => {
+ it("reads a plausible control set", () => {
+ // Sanity floor: if the control shape silently degraded, every assertion
+ // below would pass vacuously.
+ expect(CONTROL_KEYS.length).toBeGreaterThanOrEqual(20);
+ expect(CONTROL_KEYS).toContain("bp");
+ expect(CONTROL_KEYS).toContain("mentalHealthScreeners");
+ });
+
+ it("renders a row for every control it declares", () => {
+ const unrendered = CONTROL_KEYS.filter(
+ (key) => !panelSource.includes(`sections.${key}`),
+ );
+ expect(
+ unrendered,
+ `These controls are declared in the selection shape but the panel binds ` +
+ `no row to them, so the user has no way to reach them.`,
+ ).toEqual([]);
+ });
+
+ it("keeps every rendered control inside the wire schema", () => {
+ const wire = buildSelectionSections(allOn());
+ // A round trip through the schema must not lose anything the panel sends.
+ expect(exportSectionsSchema.parse(wire)).toEqual(wire);
+ });
+
+ it("makes flipping any single control change the resolved selection", () => {
+ const baseline = resolve(allOn());
+ const inert: string[] = [];
+
+ for (const key of CONTROL_KEYS) {
+ const flipped = resolve({ ...allOn(), [key]: false });
+ if (JSON.stringify(flipped) === JSON.stringify(baseline)) {
+ inert.push(key);
+ }
+ }
+
+ expect(
+ inert,
+ `These panel controls are rendered but change nothing in the resolved ` +
+ `export selection. Give each one a flag in DoctorReportPrefs and a ` +
+ `gating path in doctor-report-data.ts, or remove the control.`,
+ ).toEqual([]);
+ });
+
+ it("has something downstream read every flag in the resolved selection", () => {
+ // A flag is honoured either by gating measurement types or by name in the
+ // aggregator body (the structured sections: labs, allergies, the
+ // medication block, the glucose panel).
+ const mappedFlags = new Set(
+ Object.values(MEASUREMENT_TYPE_SECTION),
+ );
+ const body = stripComments(aggregatorSource);
+
+ const unread = Object.keys(DEFAULT_DOCTOR_REPORT_PREFS).filter(
+ (key) => !mappedFlags.has(key) && !body.includes(`sections.${key}`),
+ );
+
+ expect(
+ unread,
+ `These resolved selection flags are never read downstream, so switching ` +
+ `them off would change nothing that leaves the instance.`,
+ ).toEqual([]);
+ });
+
+ it("keeps the sensitive selections off unless asked for by name", () => {
+ // Mood, cycle and the screening instruments must never ride an absent key:
+ // the mental-health module went default-ON in v1.29.1 and silently made
+ // PHQ-9 and GAD-7 totals part of a default export.
+ const fromNothing = toDoctorReportPrefs(undefined);
+ expect(fromNothing.mood).toBe(false);
+ expect(fromNothing.cycle).toBe(false);
+ expect(fromNothing.mentalHealthScreeners).toBe(false);
+
+ // A partial selection that names other things must not flip them on.
+ const partial = toDoctorReportPrefs({ labs: true, vitals: { bp: true } });
+ expect(partial.mood).toBe(false);
+ expect(partial.cycle).toBe(false);
+ expect(partial.mentalHealthScreeners).toBe(false);
+
+ // Only an explicit `true` does.
+ expect(
+ toDoctorReportPrefs({ mentalHealthScreeners: true })
+ .mentalHealthScreeners,
+ ).toBe(true);
+ });
+});
diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
index b5c5a5584..d993076dc 100644
--- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
+++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
@@ -99,10 +99,13 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
const res = await (PUT as (r: Request) => Promise)(mkPut(body));
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof body };
- expect(env.data).toEqual(body);
+ // The canonical shape is the full prefs object: keys the body omits fill
+ // from the defaults rather than being dropped.
+ const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body };
+ expect(env.data).toEqual(canonical);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
- data: { doctorReportPrefsJson: body },
+ data: { doctorReportPrefsJson: canonical },
});
});
@@ -134,10 +137,15 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof current };
- expect(env.data).toEqual({ ...current, mood: false });
+ const merged = {
+ ...DEFAULT_DOCTOR_REPORT_PREFS,
+ ...current,
+ mood: false,
+ };
+ expect(env.data).toEqual(merged);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
- data: { doctorReportPrefsJson: { ...current, mood: false } },
+ data: { doctorReportPrefsJson: merged },
});
});
diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts
index 2edf0ab3c..e5815ce7b 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { PDFParse } from "pdf-parse";
+import { unzipSync } from "fflate";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -31,9 +32,15 @@ vi.mock("@/lib/auth/audit", () => ({
}));
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
-vi.mock("@/lib/tz/resolver", () => ({
- resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
-}));
+// Partial: the aggregator's canonical-source collapse calls `userDayKey` from
+// the same module, so a total mock breaks any fixture that carries rows.
+vi.mock("@/lib/tz/resolver", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
+ };
+});
// v1.18.0 — the health-record aggregator resolves the per-user module map
// so a disabled data-domain module never reaches the export. Stub the gate
@@ -250,6 +257,287 @@ describe("POST /api/export/health-record — outputs", () => {
});
});
+/**
+ * The selection has to mean the same thing in every artefact.
+ *
+ * Three defects met here. The export panel rendered eight measurement switches
+ * that the grouped-to-flat fold discarded, so deselecting body fat or VO₂max
+ * exported them anyway. The medication switch documented itself as excluding
+ * all medication data while the drug list and the per-dose ledger went out
+ * unconditionally. And the route fetched allergies and family history without
+ * consulting the toggles at all, so `sections.allergies = false` produced a PDF
+ * without an allergy section and a FHIR bundle carrying every one of them — in
+ * the `package` format, inside the same zip.
+ *
+ * Each case below runs the real route end to end and reads the produced
+ * artefact. Mutation check: revert any single gate and its case goes red while
+ * the positive control beside it stays green, so none of them can pass by
+ * asserting on an empty fixture.
+ */
+describe("POST /api/export/health-record — the selection reaches every format", () => {
+ const T0 = new Date("2026-02-01T08:00:00.000Z");
+
+ function measurement(type: string, value: number) {
+ return {
+ type,
+ value,
+ measuredAt: T0,
+ source: "MANUAL",
+ deviceType: null,
+ sleepStage: null,
+ glucoseContext: null,
+ };
+ }
+
+ function allergyRow() {
+ return {
+ id: "allergy-1",
+ substance: "Penicillin",
+ category: "MEDICATION",
+ type: "ALLERGY",
+ severity: "SEVERE",
+ status: "ACTIVE",
+ onsetAt: null,
+ reactionEncrypted: null,
+ notesEncrypted: null,
+ createdAt: T0,
+ updatedAt: T0,
+ };
+ }
+
+ function familyHistoryRow() {
+ return {
+ id: "fmh-1",
+ relationship: "MOTHER",
+ condition: "Hypertension",
+ ageAtOnset: 52,
+ notesEncrypted: null,
+ createdAt: T0,
+ updatedAt: T0,
+ };
+ }
+
+ function medicationRow() {
+ return {
+ id: "med-1",
+ name: "Sample medication",
+ dose: "5 mg",
+ atcCode: null,
+ rxNormCode: null,
+ deliveryForm: null,
+ active: true,
+ schedules: [],
+ scheduleRevisions: [],
+ pauseEras: [],
+ doseChanges: [],
+ intakeEvents: [],
+ };
+ }
+
+ /** Resource types present in the bundle a given selection produces. */
+ async function bundleResourceTypes(
+ sections: Record,
+ ): Promise {
+ const { POST } = await import("../route");
+ const res = await POST(mkReq({ format: "fhir", sections }));
+ expect(res.status).toBe(200);
+ const bundle = (await res.json()) as {
+ entry: { resource: { resourceType: string; code?: unknown } }[];
+ };
+ return bundle.entry.map((e) => e.resource.resourceType);
+ }
+
+ /** The LOINC/HealthKit codes of every Observation a selection produces. */
+ async function observationCodes(
+ sections: Record,
+ ): Promise {
+ const { POST } = await import("../route");
+ const res = await POST(mkReq({ format: "fhir", sections }));
+ expect(res.status).toBe(200);
+ const bundle = (await res.json()) as {
+ entry: {
+ resource: {
+ resourceType: string;
+ code?: { coding?: { code: string }[]; text?: string };
+ };
+ }[];
+ };
+ return bundle.entry
+ .filter((e) => e.resource.resourceType === "Observation")
+ .flatMap((e) => [
+ ...(e.resource.code?.coding?.map((c) => c.code) ?? []),
+ e.resource.code?.text ?? "",
+ ]);
+ }
+
+ it("honours every previously-dead measurement switch in the FHIR bundle", async () => {
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ measurement("BODY_FAT", 21),
+ measurement("OXYGEN_SATURATION", 97),
+ measurement("BONE_MASS", 3.1),
+ measurement("RESTING_HEART_RATE", 54),
+ measurement("HEART_RATE_VARIABILITY", 61),
+ measurement("VO2_MAX", 44),
+ measurement("ACTIVITY_STEPS", 8400),
+ measurement("WALKING_RUNNING_DISTANCE", 6200),
+ ] as never);
+
+ // Positive control: with every switch on, all eight are emitted. Without
+ // this the exclusion assertion below could pass on an empty bundle.
+ const on = await observationCodes({
+ vitals: { oxygenSaturation: true, bodyFat: true, bodyComposition: true },
+ cardioFitness: { restingHeartRate: true, hrv: true, vo2max: true },
+ activity: { steps: true, distance: true },
+ });
+ expect(on.length).toBeGreaterThanOrEqual(8);
+
+ const off = await observationCodes({
+ vitals: {
+ oxygenSaturation: false,
+ bodyFat: false,
+ bodyComposition: false,
+ },
+ cardioFitness: { restingHeartRate: false, hrv: false, vo2max: false },
+ activity: { steps: false, distance: false },
+ });
+ expect(off).toEqual([]);
+ });
+
+ it("keeps the deselected measurement rows out of the query in the first place", async () => {
+ await bundleResourceTypes({
+ vitals: { bodyFat: false },
+ cardioFitness: { vo2max: false },
+ });
+ const lastCall = vi.mocked(prisma.measurement.findMany).mock.calls.at(-1)!;
+ const where = lastCall[0]!.where as { type?: { notIn?: string[] } };
+ expect(where.type?.notIn).toContain("BODY_FAT");
+ expect(where.type?.notIn).toContain("VO2_MAX");
+ });
+
+ it("omits the deselected vitals from the PDF table", async () => {
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ measurement("BODY_FAT", 21),
+ measurement("OXYGEN_SATURATION", 97),
+ ] as never);
+ const { POST } = await import("../route");
+
+ const withBoth = await pdfText(
+ await POST(
+ mkReq({
+ format: "pdf",
+ locale: "en",
+ sections: { vitals: { bodyFat: true, oxygenSaturation: true } },
+ }),
+ ),
+ );
+ expect(withBoth).toContain("Body fat");
+
+ const without = await pdfText(
+ await POST(
+ mkReq({
+ format: "pdf",
+ locale: "en",
+ sections: { vitals: { bodyFat: false, oxygenSaturation: false } },
+ }),
+ ),
+ );
+ expect(without).not.toContain("Body fat");
+ });
+
+ it("holds the mental-health screening totals back unless they are asked for by name", async () => {
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([
+ measurement("PHQ9_SCORE", 11),
+ measurement("GAD7_SCORE", 8),
+ ] as never);
+
+ // The module is on (the default since v1.29.1) and the caller sends no
+ // screening key: the totals must stay in the database.
+ expect(await observationCodes({ labs: true })).toEqual([]);
+ // And an omitted `sections` object entirely — the MCP / share-link case.
+ const { POST } = await import("../route");
+ const res = await POST(mkReq({ format: "fhir" }));
+ const bundle = (await res.json()) as {
+ entry: { resource: { resourceType: string } }[];
+ };
+ expect(
+ bundle.entry.filter((e) => e.resource.resourceType === "Observation"),
+ ).toEqual([]);
+
+ // Asked for explicitly, they are emitted — the data is withheld, not lost.
+ const asked = await observationCodes({ mentalHealthScreeners: true });
+ expect(asked).toContain("44261-6");
+ expect(asked).toContain("70274-6");
+ });
+
+ it("lets the medication switch withhold the medication list", async () => {
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([
+ medicationRow(),
+ ] as never);
+
+ expect(
+ await bundleResourceTypes({ medications: { list: true } }),
+ ).toContain("MedicationStatement");
+ expect(
+ await bundleResourceTypes({ medications: { list: false } }),
+ ).not.toContain("MedicationStatement");
+ });
+
+ it("keeps allergies and family history out of the FHIR bundle when deselected", async () => {
+ vi.mocked(prisma.allergy.findMany).mockResolvedValue([
+ allergyRow(),
+ ] as never);
+ vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([
+ familyHistoryRow(),
+ ] as never);
+
+ const on = await bundleResourceTypes({
+ allergies: true,
+ familyHistory: true,
+ });
+ expect(on).toContain("AllergyIntolerance");
+ expect(on).toContain("FamilyMemberHistory");
+
+ const off = await bundleResourceTypes({
+ allergies: false,
+ familyHistory: false,
+ });
+ expect(off).not.toContain("AllergyIntolerance");
+ expect(off).not.toContain("FamilyMemberHistory");
+ });
+
+ it("never reads the deselected records from the database", async () => {
+ await bundleResourceTypes({ allergies: false, familyHistory: false });
+ expect(prisma.allergy.findMany).not.toHaveBeenCalled();
+ expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled();
+ });
+
+ it("ships a package whose bundle agrees with its PDF", async () => {
+ vi.mocked(prisma.allergy.findMany).mockResolvedValue([
+ allergyRow(),
+ ] as never);
+ vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([
+ familyHistoryRow(),
+ ] as never);
+
+ const { POST } = await import("../route");
+ const res = await POST(
+ mkReq({
+ format: "package",
+ sections: { allergies: false, familyHistory: false },
+ }),
+ );
+ expect(res.status).toBe(200);
+ const files = unzipSync(new Uint8Array(await res.arrayBuffer()));
+ const bundle = JSON.parse(
+ new TextDecoder().decode(files["bundle.json"]!),
+ ) as { entry: { resource: { resourceType: string } }[] };
+ const types = bundle.entry.map((e) => e.resource.resourceType);
+ // The compliant PDF and the violating bundle used to travel together.
+ expect(types).not.toContain("AllergyIntolerance");
+ expect(types).not.toContain("FamilyMemberHistory");
+ });
+});
+
/**
* The practice name is a remembered preference (`User.lastReportPracticeName`),
* so a monthly report does not ask for the clinic name again every time. The
diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts
index e040c6b6e..57ae2e92b 100644
--- a/src/app/api/share-links/__tests__/route.test.ts
+++ b/src/app/api/share-links/__tests__/route.test.ts
@@ -64,8 +64,6 @@ function validBody(overrides: Record = {}) {
label: "Cardiology clinic",
rangeStart: "2026-01-01T00:00:00Z",
rangeEnd: null,
- resourceTypes: ["Observation"],
- allowFhirApi: true,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
...overrides,
};
@@ -77,8 +75,6 @@ function storedRow(overrides: Record = {}) {
label: "Cardiology clinic",
rangeStart: new Date("2026-01-01T00:00:00Z"),
rangeEnd: null,
- resourceTypes: ["Observation"],
- allowFhirApi: true,
passphraseHash: "hash(STORED)",
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
@@ -241,22 +237,27 @@ describe("POST /api/share-links — create", () => {
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
});
+ it("rejects the retired FHIR scope keys with 422", async () => {
+ // The share link has never served FHIR against its token. The keys
+ // are gone from the create schema rather than accepted and ignored, so a
+ // stale client is told plainly instead of believing it scoped something.
+ const res = await POST(
+ postReq(
+ validBody({ allowFhirApi: true, resourceTypes: ["Observation"] }),
+ ),
+ );
+ expect(res.status).toBe(422);
+ expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
+ });
+
it("allows a documents-only share (empty report sections)", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
{ id: "doc-a" },
] as never);
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
- storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
- );
- const res = await POST(
- postReq(
- validBody({
- resourceTypes: [],
- allowFhirApi: false,
- documentIds: ["doc-a"],
- }),
- ),
+ storedRow({ _count: { documents: 1 } }) as never,
);
+ const res = await POST(postReq(validBody({ documentIds: ["doc-a"] })));
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { documentCount: number } };
expect(body.data.documentCount).toBe(1);
@@ -267,18 +268,15 @@ describe("POST /api/share-links — create", () => {
{ id: "doc-a" },
] as never);
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
- storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
+ storedRow({ _count: { documents: 1 } }) as never,
);
// A hostile / stale client tries to widen a document link into a record
- // share: FHIR on, resource types set, sections requested. The server must
- // ignore every one of them.
+ // share by requesting sections. The server must ignore them.
const res = await POST(
postReq(
validBody({
documentOnly: true,
- allowFhirApi: true,
- resourceTypes: ["Observation", "Patient"],
sections: { vitals: { bp: true }, labs: true },
documentIds: ["doc-a"],
}),
@@ -288,9 +286,9 @@ describe("POST /api/share-links — create", () => {
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
.calls[0][0];
- // Empty FHIR scope, FHIR API off — no health surface at all.
- expect(createArg.data.resourceTypes).toEqual([]);
- expect(createArg.data.allowFhirApi).toBe(false);
+ // The retired FHIR scope columns are never written at all.
+ expect(createArg.data).not.toHaveProperty("resourceTypes");
+ expect(createArg.data).not.toHaveProperty("allowFhirApi");
// Sections persist as an explicit all-OFF blob (distinct from `{}` = full
// record defaults). The clinician view reads this as "no report".
expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS);
diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx
index 55b121b09..35416057d 100644
--- a/src/app/c/[token]/__tests__/page.test.tsx
+++ b/src/app/c/[token]/__tests__/page.test.tsx
@@ -94,8 +94,6 @@ function resolvedContext() {
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
- resourceTypes: [],
- allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
};
}
diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts
index 96072c5c7..93adec25a 100644
--- a/src/app/c/[token]/d/[id]/__tests__/route.test.ts
+++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts
@@ -159,8 +159,6 @@ beforeEach(() => {
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
- resourceTypes: [],
- allowFhirApi: false,
expiresAt: new Date(Date.now() + 3_600_000),
} as never);
});
diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx
index 707c63ca1..96e17aef8 100644
--- a/src/components/settings/__tests__/share-link-create-form.test.tsx
+++ b/src/components/settings/__tests__/share-link-create-form.test.tsx
@@ -112,19 +112,17 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
expiryDays: 30,
};
- it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => {
+ it("document-only: empty report scope and the launched doc always rides along", () => {
const payload = buildShareLinkCreatePayload({
...common,
- // Even if the caller state still holds record-scope values, they must be
- // dropped: a document link never carries a health scope.
- allowFhirApi: true,
- resourceTypes: ["Patient", "Observation"],
documentIds: ["doc-1"],
documentOnly: true,
});
- expect(payload.resourceTypes).toEqual([]);
- expect(payload.allowFhirApi).toBe(false);
expect(payload.documentOnly).toBe(true);
+ // The retired FHIR scope keys are gone from the wire entirely,
+ // so a share link can no longer advertise a face that was never served.
+ expect(payload).not.toHaveProperty("resourceTypes");
+ expect(payload).not.toHaveProperty("allowFhirApi");
// No report sections are ever sent for a document link.
expect(payload).not.toHaveProperty("sections");
// The launched document is always in the created link (pre-attach fix).
@@ -132,15 +130,15 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
expect(payload.label).toBe("Blood panel 2026");
});
- it("record share: keeps the full scope and omits documentIds when none picked", () => {
+ it("record share: keeps the window scope and omits documentIds when none picked", () => {
const payload = buildShareLinkCreatePayload({
...common,
- allowFhirApi: false,
- resourceTypes: ["Patient", "Observation"],
documentIds: [],
documentOnly: false,
});
- expect(payload.resourceTypes).toEqual(["Patient", "Observation"]);
+ expect(payload.rangeEnd).toBeNull();
+ expect(payload).not.toHaveProperty("resourceTypes");
+ expect(payload).not.toHaveProperty("allowFhirApi");
expect(payload).not.toHaveProperty("documentIds");
expect(payload).not.toHaveProperty("documentOnly");
});
@@ -148,8 +146,6 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
it("record share: attaches the picked documents when the owner chose some", () => {
const payload = buildShareLinkCreatePayload({
...common,
- allowFhirApi: false,
- resourceTypes: ["Patient", "Observation"],
documentIds: ["a", "b"],
documentOnly: false,
});
diff --git a/src/components/settings/__tests__/sharing-section.test.tsx b/src/components/settings/__tests__/sharing-section.test.tsx
index 81a5171f4..7cadc1354 100644
--- a/src/components/settings/__tests__/sharing-section.test.tsx
+++ b/src/components/settings/__tests__/sharing-section.test.tsx
@@ -33,8 +33,6 @@ const FIXTURE_LINKS = [
label: "Cardiology referral",
rangeStart: "2026-01-01T00:00:00.000Z",
rangeEnd: null,
- resourceTypes: ["Patient", "Observation"],
- allowFhirApi: true,
documentCount: 2,
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-06-01T00:00:00.000Z",
@@ -48,8 +46,6 @@ const FIXTURE_LINKS = [
label: "Old GP link",
rangeStart: "2026-01-01T00:00:00.000Z",
rangeEnd: null,
- resourceTypes: [],
- allowFhirApi: false,
documentCount: 0,
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-05-01T00:00:00.000Z",
@@ -100,8 +96,9 @@ describe(" — owner share-link surface (C7)", () => {
// The list response shape carries no token; the markup must not surface
// an `hls_` string anywhere.
expect(html).not.toContain("hls_");
- // The FHIR badge surfaces for the FHIR-enabled link.
- expect(html).toContain("FHIR");
+ // No FHIR badge: the link has never served FHIR, so advertising
+ // it on the owner's list was the last place the promise still showed.
+ expect(html).not.toContain("FHIR");
});
it("folds revoked links into the inactive list (not the active list)", () => {
diff --git a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts
index 814683dc1..c7252dd65 100644
--- a/src/lib/clinician-share/__tests__/resolve-share-token.test.ts
+++ b/src/lib/clinician-share/__tests__/resolve-share-token.test.ts
@@ -39,8 +39,6 @@ function liveRow(overrides: Record = {}) {
rangeStart: new Date("2026-01-01T00:00:00.000Z"),
rangeEnd: null,
sectionsJson: { bp: true },
- resourceTypes: ["Observation"],
- allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
revokedAt: null,
...overrides,
@@ -59,7 +57,9 @@ describe("resolveShareToken", () => {
expect(ctx).not.toBeNull();
expect(ctx?.ownerUserId).toBe("owner-1");
expect(ctx?.shareLinkId).toBe("link-1");
- expect(ctx?.resourceTypes).toEqual(["Observation"]);
+ // The retired FHIR scope no longer rides the resolved context.
+ expect(ctx).not.toHaveProperty("resourceTypes");
+ expect(ctx).not.toHaveProperty("allowFhirApi");
// The context must carry ONLY the owner scope — never a session/user/role.
expect(ctx).not.toHaveProperty("session");
expect(ctx).not.toHaveProperty("user");
diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts
index 5eac1b659..2317f375f 100644
--- a/src/lib/clinician-share/__tests__/share-view-data.test.ts
+++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts
@@ -45,8 +45,6 @@ function ctx(overrides: Partial = {}): ShareContext {
rangeStart: new Date("2026-01-01T00:00:00Z"),
rangeEnd: new Date("2026-02-01T00:00:00Z"),
sectionsJson: { mood: false },
- resourceTypes: [],
- allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
...overrides,
} as ShareContext;
diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts
index c3abd34fc..e8073ab02 100644
--- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts
+++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts
@@ -109,7 +109,14 @@ describe("resolveDoctorReportPrefs", () => {
familyHistory: false,
};
const out = resolveDoctorReportPrefs(current, { mood: false });
- expect(out).toEqual({ ...current, mood: false });
+ // Keys the persisted row predates (this change added the measurement-group,
+ // medication-list and screener flags) fill from the defaults, so the
+ // resolved shape is always complete for the aggregator.
+ expect(out).toEqual({
+ ...DEFAULT_DOCTOR_REPORT_PREFS,
+ ...current,
+ mood: false,
+ });
});
it("layers a partial update over defaults when current is null", () => {
From 5e490f31ec0e30534d526a75a03ef1e83ad59e77 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 17:51:33 +0200
Subject: [PATCH 03/17] feat(export): make the doctor report carry only what
was chosen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.
A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.
Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.
The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.
Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.
Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.
The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.
The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".
Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.
The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.
Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
---
.../0273_report_selection/migration.sql | 29 +
prisma/schema.prisma | 43 +-
.../__tests__/route.test.ts | 210 ---
.../api/auth/me/doctor-report-prefs/route.ts | 109 --
src/app/api/auth/me/report-selection/route.ts | 109 ++
src/app/api/export/health-record/route.ts | 288 ++--
src/app/api/meta/capabilities/route.ts | 26 +-
src/app/api/share-links/route.ts | 92 +-
src/app/c/[token]/fhir/route.ts | 46 +
src/app/c/[token]/page.tsx | 38 +-
src/app/c/[token]/report.pdf/route.ts | 51 +
src/components/clinician/clinician-view.tsx | 333 +---
src/components/clinician/documents-list.tsx | 98 ++
src/components/clinician/download-actions.tsx | 46 +
src/components/clinician/report-sections.tsx | 232 +++
src/lib/clinician-share/report-download.ts | 132 ++
src/lib/clinician-share/request-locale.ts | 26 +
src/lib/clinician-share/share-view-data.ts | 31 +-
src/lib/doctor-report-data.ts | 1430 +----------------
src/lib/doctor-report-pdf-core.ts | 94 +-
.../clinical-records-notes-section.ts | 59 +-
src/lib/doctor-report-pdf/clinical-summary.ts | 5 +-
.../measurements-charts-section.ts | 93 +-
src/lib/doctor-report-pdf/render-context.ts | 12 +-
src/lib/doctor-report-selection.ts | 177 --
src/lib/doctor-report-types.ts | 26 +-
src/lib/doctor-report/clinical-records.ts | 157 ++
src/lib/doctor-report/collect.ts | 577 +++++++
src/lib/doctor-report/dense-buckets.ts | 211 +++
src/lib/doctor-report/glucose-panel.ts | 146 ++
src/lib/doctor-report/measurement-series.ts | 161 ++
src/lib/doctor-report/medications.ts | 259 +++
src/lib/doctor-report/selection-gate.ts | 103 ++
src/lib/doctor-report/type-label-keys.ts | 24 -
src/lib/export/health-record-artefacts.ts | 76 +
src/lib/mcp/prompts.ts | 17 +-
src/lib/mcp/resources.ts | 13 +-
src/lib/measurements/type-label-keys.ts | 110 ++
src/lib/openapi/routes/meta.ts | 21 +-
src/lib/report-selection/catalogue.ts | 380 +++++
src/lib/report-selection/saved-profile.ts | 72 +
src/lib/report-selection/selection.ts | 131 ++
src/lib/report-selection/template.ts | 46 +
src/lib/validations/clinician-share-link.ts | 26 +-
src/lib/validations/doctor-report-prefs.ts | 228 ---
src/lib/validations/health-record-export.ts | 224 +--
46 files changed, 3734 insertions(+), 3083 deletions(-)
create mode 100644 prisma/migrations/0273_report_selection/migration.sql
delete mode 100644 src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
delete mode 100644 src/app/api/auth/me/doctor-report-prefs/route.ts
create mode 100644 src/app/api/auth/me/report-selection/route.ts
create mode 100644 src/app/c/[token]/fhir/route.ts
create mode 100644 src/app/c/[token]/report.pdf/route.ts
create mode 100644 src/components/clinician/documents-list.tsx
create mode 100644 src/components/clinician/download-actions.tsx
create mode 100644 src/components/clinician/report-sections.tsx
create mode 100644 src/lib/clinician-share/report-download.ts
create mode 100644 src/lib/clinician-share/request-locale.ts
delete mode 100644 src/lib/doctor-report-selection.ts
create mode 100644 src/lib/doctor-report/clinical-records.ts
create mode 100644 src/lib/doctor-report/collect.ts
create mode 100644 src/lib/doctor-report/dense-buckets.ts
create mode 100644 src/lib/doctor-report/glucose-panel.ts
create mode 100644 src/lib/doctor-report/measurement-series.ts
create mode 100644 src/lib/doctor-report/medications.ts
create mode 100644 src/lib/doctor-report/selection-gate.ts
delete mode 100644 src/lib/doctor-report/type-label-keys.ts
create mode 100644 src/lib/export/health-record-artefacts.ts
create mode 100644 src/lib/measurements/type-label-keys.ts
create mode 100644 src/lib/report-selection/catalogue.ts
create mode 100644 src/lib/report-selection/saved-profile.ts
create mode 100644 src/lib/report-selection/selection.ts
create mode 100644 src/lib/report-selection/template.ts
delete mode 100644 src/lib/validations/doctor-report-prefs.ts
diff --git a/prisma/migrations/0273_report_selection/migration.sql b/prisma/migrations/0273_report_selection/migration.sql
new file mode 100644
index 000000000..7325c56c2
--- /dev/null
+++ b/prisma/migrations/0273_report_selection/migration.sql
@@ -0,0 +1,29 @@
+-- The owner's saved report selection: the leaf-id inclusion list plus the
+-- format / range / charts choices the export panel restores.
+ALTER TABLE "users" ADD COLUMN "report_selection_json" JSONB;
+
+-- Retire the orphaned flat prefs column. No code outside its own route ever
+-- read it — the route's docblock claimed the aggregator consulted it, which
+-- was not true — and the route is removed in the same release.
+ALTER TABLE "users" DROP COLUMN "doctor_report_prefs_json";
+
+-- Retire the share-link FHIR scope columns. The face they described was never
+-- built and their controls are gone. An inert column with a plausible name is
+-- precisely what a later contributor rewires by mistake.
+ALTER TABLE "clinician_share_links" DROP COLUMN "resource_types";
+ALTER TABLE "clinician_share_links" DROP COLUMN "allow_fhir_api";
+
+-- Revoke every live RECORD share link minted before the selection model. Their
+-- frozen scope is `{}` or a flat prefs blob, both of which resolved to
+-- "everything except mood and cycle" — a scope nobody chose. Downgrading them
+-- to something narrower would still be a scope nobody chose, so they close and
+-- the owner re-mints from the sharing list, which shows each one in a
+-- pick-again state with its label, window and expiry preserved.
+--
+-- Documents-only shares are untouched: they carry an explicit empty report
+-- scope and serve no health metric.
+UPDATE "clinician_share_links"
+ SET "revoked_at" = NOW()
+ WHERE "revoked_at" IS NULL
+ AND "document_only" = false
+ AND "expires_at" > NOW();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2aad4be6a..90ca68d2b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -491,19 +491,22 @@ model User {
// `MoodReminderDispatch` table (one row per user + local date).
moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled")
- // v1.4.25 W6c — Doctor-report section toggles.
- // Shape:
- // { "bp": boolean,
- // "weight": boolean,
- // "pulse": boolean,
- // "bmi": boolean,
- // "mood": boolean, // privacy-sensitive; default OFF
- // "compliance": boolean,
- // "sleep": boolean }
- // Null = documented defaults (every section ON except mood). Mood is
- // opt-in by default — the API layer drops mood entirely from the
- // payload when the toggle is off so the data never leaves the DB row.
- doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
+ // The owner's saved report selection: the leaf-id inclusion list plus the
+ // format / range / charts choices the export panel restores. Shape:
+ // { "v": 2,
+ // "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …],
+ // "format": "pdf" | "fhir" | "package",
+ // "rangeDays": number,
+ // "includeCharts": boolean }
+ // Null = never saved, which resolves to the EMPTY selection everywhere.
+ // Read by the export panel, the share-link create form, the FHIR REST face
+ // and the MCP doctor-visit surfaces.
+ //
+ // Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not
+ // express "not chosen": an absent key resolved to included, so a caller who
+ // said nothing received the whole record. Membership is the whole contract
+ // here — absence is exclusion, and there is no defaults constant to drift.
+ reportSelectionJson Json? @map("report_selection_json")
// v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
//
@@ -5378,18 +5381,10 @@ model ClinicianShareLink {
rangeStart DateTime @map("range_start")
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
rangeEnd DateTime? @map("range_end")
- /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON.
+ /// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
+ /// mint, never widened. A blob this build cannot read resolves to the empty
+ /// selection, so a drifted row serves nothing rather than defaulting open.
sectionsJson Json @map("sections_json")
- /// RETIRED. These two columns described a scoped FHIR face for a
- /// share link that was never built: no route ever served FHIR (or any
- /// machine-readable format) against an `hls_` token, so the create form's
- /// toggle, its resource-type picker, the list badge and the API description
- /// all promised reachability that did not exist. The controls are gone, the
- /// create path no longer writes anything but the defaults, and no read path
- /// touches either column — the values still on rows minted earlier are inert.
- /// Kept as columns so no data is destroyed to undo a promise.
- resourceTypes String[] @map("resource_types")
- allowFhirApi Boolean @default(false) @map("allow_fhir_api")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
deleted file mode 100644
index d993076dc..000000000
--- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-vi.mock("@/lib/db", () => ({
- prisma: {
- user: {
- findUnique: vi.fn(),
- update: vi.fn(),
- },
- },
- toJson: (v: T) => v,
-}));
-
-vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
-
-vi.mock("@/lib/auth/audit", () => ({
- auditLog: vi.fn().mockResolvedValue(undefined),
-}));
-
-vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
-
-vi.mock("@/lib/db-compat", () => ({
- ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
-}));
-
-vi.mock("next/headers", () => ({
- headers: vi.fn(async () => ({ get: () => null })),
- cookies: vi.fn(async () => ({
- get: () => undefined,
- set: () => {},
- delete: () => {},
- })),
-}));
-
-import { GET, PUT } from "../route";
-import { prisma } from "@/lib/db";
-import { getSession } from "@/lib/auth/session";
-import { auditLog } from "@/lib/auth/audit";
-import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
-
-const SESSION_OK = {
- session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "testuser", role: "USER" as const },
-};
-
-function mkPut(body: unknown): Request {
- return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
- method: "PUT",
- body: JSON.stringify(body),
- headers: { "Content-Type": "application/json" },
- });
-}
-
-beforeEach(() => {
- vi.clearAllMocks();
-});
-
-describe("GET /api/auth/me/doctor-report-prefs", () => {
- it("returns defaults when the column is null", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- doctorReportPrefsJson: null,
- } as never);
-
- const res = await (GET as (r: Request) => Promise)(
- new Request("http://localhost/api/auth/me/doctor-report-prefs"),
- );
- expect(res.status).toBe(200);
- const env = (await res.json()) as {
- data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
- };
- expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
- expect(env.data.mood).toBe(false); // privacy default per the maintainer
- });
-});
-
-describe("PUT /api/auth/me/doctor-report-prefs", () => {
- // ── Case 1 — happy path ─────────────────────────────────────────────
- it("persists the full shape and returns the canonical form", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- doctorReportPrefsJson: null,
- } as never);
- vi.mocked(prisma.user.update).mockResolvedValue({} as never);
-
- const body = {
- bp: true,
- weight: true,
- pulse: false,
- bmi: true,
- mood: true,
- compliance: true,
- sleep: false,
- glucose: true,
- cycle: false,
- labs: true,
- allergies: true,
- familyHistory: false,
- };
- const res = await (PUT as (r: Request) => Promise)(mkPut(body));
- expect(res.status).toBe(200);
- const env = (await res.json()) as { data: typeof body };
- // The canonical shape is the full prefs object: keys the body omits fill
- // from the defaults rather than being dropped.
- const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body };
- expect(env.data).toEqual(canonical);
- expect(prisma.user.update).toHaveBeenCalledWith({
- where: { id: "user-1" },
- data: { doctorReportPrefsJson: canonical },
- });
- });
-
- // ── Case 2 — partial update layers over current persisted row ───────
- it("merges a partial update over the persisted row", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- const current = {
- bp: false,
- weight: false,
- pulse: false,
- bmi: false,
- mood: true,
- compliance: false,
- sleep: false,
- glucose: false,
- cycle: false,
- labs: false,
- allergies: false,
- familyHistory: false,
- };
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- doctorReportPrefsJson: current,
- } as never);
- vi.mocked(prisma.user.update).mockResolvedValue({} as never);
-
- // User only flips mood off — every other key keeps its persisted value.
- const res = await (PUT as (r: Request) => Promise)(
- mkPut({ mood: false }),
- );
- expect(res.status).toBe(200);
- const env = (await res.json()) as { data: typeof current };
- const merged = {
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...current,
- mood: false,
- };
- expect(env.data).toEqual(merged);
- expect(prisma.user.update).toHaveBeenCalledWith({
- where: { id: "user-1" },
- data: { doctorReportPrefsJson: merged },
- });
- });
-
- // ── Case 3 — invalid shape ─────────────────────────────────────────
- it("rejects a non-boolean value with 422", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
-
- const res = await (PUT as (r: Request) => Promise)(
- mkPut({ bp: "nope" }),
- );
- expect(res.status).toBe(422);
- expect(prisma.user.update).not.toHaveBeenCalled();
- });
-
- // ── Case 4 — unauthenticated cross-user request ────────────────────
- // the maintainer's spec mentions "cross-user 404" — the route layer returns 401
- // when there is no session at all, which is the only "you are not the
- // right user" surface available before requireAuth() resolves an
- // identity. `prisma.user.update` must NOT fire in this case.
- it("rejects an unauthenticated request with 401", async () => {
- vi.mocked(getSession).mockResolvedValue(null);
-
- const res = await (PUT as (r: Request) => Promise)(
- mkPut({ mood: false }),
- );
- expect(res.status).toBe(401);
- expect(prisma.user.update).not.toHaveBeenCalled();
- });
-
- // ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
- it("writes an audit-log entry with the previous and new pref shape", async () => {
- vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
- const current = {
- bp: true,
- weight: true,
- pulse: true,
- bmi: true,
- mood: false,
- compliance: true,
- sleep: true,
- };
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
- doctorReportPrefsJson: current,
- } as never);
- vi.mocked(prisma.user.update).mockResolvedValue({} as never);
-
- const res = await (PUT as (r: Request) => Promise)(
- mkPut({ mood: true }),
- );
- expect(res.status).toBe(200);
- expect(auditLog).toHaveBeenCalledWith(
- "user.doctor-report-prefs.update",
- expect.objectContaining({
- userId: "user-1",
- details: expect.objectContaining({
- previous: current,
- next: expect.objectContaining({ mood: true }),
- }),
- }),
- );
- });
-});
diff --git a/src/app/api/auth/me/doctor-report-prefs/route.ts b/src/app/api/auth/me/doctor-report-prefs/route.ts
deleted file mode 100644
index a3fee47b7..000000000
--- a/src/app/api/auth/me/doctor-report-prefs/route.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * v1.4.25 W6c — per-user Doctor-Report section toggles.
- *
- * GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
- * (defaults when the row is null).
- * PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
- * shape over the current row
- * and persists the canonical
- * fully-resolved object so a
- * future schema migration
- * doesn't need to back-fill
- * missing keys.
- *
- * Bearer-auth + cookie-auth both work via the shared `requireAuth()`
- * helper. The doctor-report aggregator + PDF renderer both read this
- * row at generation time so mood data is dropped at the aggregator
- * layer when `mood = false` — the data never leaves the DB.
- */
-import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
-import { apiSuccess, getClientIp } from "@/lib/api-response";
-import { annotate } from "@/lib/logging/context";
-import { auditLog } from "@/lib/auth/audit";
-import { prisma, toJson } from "@/lib/db";
-import {
- doctorReportPrefsSchema,
- parseDoctorReportPrefs,
- resolveDoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
-
-export const dynamic = "force-dynamic";
-
-export const GET = apiHandler(async () => {
- const { user } = await requireAuth();
- annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
-
- const row = await prisma.user.findUnique({
- where: { id: user.id },
- select: { doctorReportPrefsJson: true },
- });
- return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
-});
-
-export const PUT = apiHandler(async (req: Request) => {
- const { user } = await requireAuth();
-
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
- }
-
- const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
- if (!parsed.success) {
- annotate({
- action: { name: "auth.me.doctor-report-prefs.put.invalid" },
- meta: { issues: parsed.error.issues.length },
- });
- throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
- }
-
- // Merge the partial input over the current row (or defaults) so a
- // dialog that only toggles `mood` doesn't have to re-state every other
- // flag. Persist the canonical fully-resolved object so the column
- // shape stays stable across future schema additions.
- const current = await prisma.user.findUnique({
- where: { id: user.id },
- select: { doctorReportPrefsJson: true },
- });
- const merged = resolveDoctorReportPrefs(
- current?.doctorReportPrefsJson,
- parsed.data,
- );
-
- await prisma.user.update({
- where: { id: user.id },
- data: {
- doctorReportPrefsJson: toJson(merged),
- },
- });
-
- // v1.4.25 W10 reconcile (security M-3): record the previous + new
- // pref shape in the audit log. Doctor-Report pref toggles
- // (especially `mood: true`) widen the PDF surface; without an
- // audit trail a silent compromise (or unintended client write)
- // is invisible. Mirrors the timezone-route audit pattern.
- await auditLog("user.doctor-report-prefs.update", {
- userId: user.id,
- ipAddress: getClientIp(req),
- details: {
- previous: current?.doctorReportPrefsJson ?? null,
- next: merged,
- },
- });
-
- annotate({
- action: { name: "auth.me.doctor-report-prefs.put" },
- meta: {
- bp: merged.bp,
- weight: merged.weight,
- pulse: merged.pulse,
- bmi: merged.bmi,
- mood: merged.mood,
- compliance: merged.compliance,
- sleep: merged.sleep,
- },
- });
- return apiSuccess(merged);
-});
diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts
new file mode 100644
index 000000000..06b9706b0
--- /dev/null
+++ b/src/app/api/auth/me/report-selection/route.ts
@@ -0,0 +1,109 @@
+/**
+ * The owner's saved report selection.
+ *
+ * GET /api/auth/me/report-selection — the saved profile, or `null` when the
+ * account has never saved one.
+ * PUT /api/auth/me/report-selection — replace it.
+ *
+ * Replace, not merge. A selection is a list of what was chosen, so merging a
+ * partial over a stored one would re-introduce exactly the failure this shape
+ * removes: a leaf staying in because the caller did not mention it. The body
+ * states the whole scope or it is not a scope.
+ *
+ * Unknown leaf ids are refused with a 422 that names them. A client sending an
+ * id this build does not know is a contract mismatch the client has to see.
+ *
+ * Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
+ * outside that route ever read.
+ */
+import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
+import { apiSuccess, getClientIp } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma, toJson } from "@/lib/db";
+import { savedReportProfileSchema } from "@/lib/report-selection/saved-profile";
+import { selectionFromRequest } from "@/lib/report-selection/selection";
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "auth.me.report-selection.get" } });
+
+ const row = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { reportSelectionJson: true },
+ });
+ const parsed = savedReportProfileSchema.safeParse(row?.reportSelectionJson);
+ // A drifted or absent blob answers `null`, not a default profile: the panel
+ // then applies the named template and shows it, which is a visible act
+ // rather than a silent server choice.
+ return apiSuccess({ profile: parsed.success ? parsed.data : null });
+});
+
+export const PUT = apiHandler(async (req: Request) => {
+ const { user } = await requireAuth();
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ throw new HttpError(422, "report-selection.body.invalid_json");
+ }
+
+ const parsed = savedReportProfileSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ annotate({
+ action: { name: "auth.me.report-selection.put.invalid" },
+ meta: { issues: parsed.error.issues.length },
+ });
+ throw new HttpError(422, "report-selection.body.invalid_shape");
+ }
+
+ const minted = selectionFromRequest(parsed.data);
+ if (!minted.ok) {
+ annotate({
+ action: { name: "auth.me.report-selection.put.invalid" },
+ meta: { unknownLeaves: minted.error.unknownLeaves },
+ });
+ throw new HttpError(422, "report-selection.leaves.unknown");
+ }
+
+ // Persist the canonical ordering rather than the caller's, so two clients
+ // that chose the same scope store the same bytes.
+ const profile = {
+ v: parsed.data.v,
+ leaves: [...minted.selection.leaves],
+ format: parsed.data.format,
+ rangeDays: parsed.data.rangeDays,
+ includeCharts: parsed.data.includeCharts,
+ };
+
+ const current = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { reportSelectionJson: true },
+ });
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { reportSelectionJson: toJson(profile) },
+ });
+
+ // The saved selection is what the FHIR REST face and the MCP doctor-visit
+ // surfaces replay for a caller that cannot ask a human, so widening it
+ // widens those too. That belongs in the audit trail.
+ await auditLog("user.report-selection.update", {
+ userId: user.id,
+ ipAddress: getClientIp(req),
+ details: {
+ previous: current?.reportSelectionJson ?? null,
+ next: profile,
+ },
+ });
+
+ annotate({
+ action: { name: "auth.me.report-selection.put" },
+ meta: { leafCount: profile.leaves.length, format: profile.format },
+ });
+ return apiSuccess({ profile });
+});
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index de9f67fe2..bf5a48e39 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -1,25 +1,25 @@
/**
- * POST /api/export/health-record — v1.7.0 flagship health-record export.
+ * POST /api/export/health-record — the doctor-handover artefact.
*
- * One entry point that produces the doctor-handover artefact in one of three
- * formats, driven by a strict Zod selection payload:
- * - `pdf` → `application/pdf` (enhanced clinical report)
+ * One entry point producing one of three formats from one aggregated payload,
+ * driven by a strict Zod selection:
+ * - `pdf` → `application/pdf`
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
- * - `package` → `application/zip` (PDF + FHIR + README in one download)
+ * - `package` → `application/zip` (PDF + Bundle + README)
*
* Auth: cookie session OR Bearer token (`requireAuth`).
- * Rate-limit: shared `export:` bucket (10/h) — so structured + PDF
- * + CSV exports cannot be parallelised past the cap.
- * Audit: `health-record.export` (records format/days/sections/charts — never
- * the values).
+ * Rate-limit: shared `export:` bucket (10/h).
+ * Audit: `health-record.export` — format, window, and the chosen leaf ids,
+ * never the values behind them.
*
- * Strict validation: unlike the legacy doctor-report route (which tolerates
- * drift), this route fails loudly — `.strict()` + `returnAllZodIssues` returns
- * a 422 multi-issue envelope on any unknown key (including any attempt to
- * smuggle a `userId`, which is always narrowed from `requireAuth()`).
+ * The `selection` field is required and carries the leaf inclusion list. An
+ * unknown leaf id is a 422 that names it; the old grouped `sections` shape is
+ * a 422 too, because accepting it would mean choosing a scope the caller did
+ * not express. The rendering itself lives in
+ * `src/lib/export/health-record-artefacts.ts`; this route keeps the HTTP
+ * concerns.
*/
import { NextRequest, NextResponse } from "next/server";
-import { zipSync, strToU8 } from "fflate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -32,7 +32,7 @@ import {
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { prisma } from "@/lib/db";
+import { prisma, toJson } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import {
@@ -40,39 +40,46 @@ import {
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
-import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
+import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/build-bundle";
+import { exportSelectionSchema } from "@/lib/validations/health-record-export";
+import { selectionFromRequest } from "@/lib/report-selection/selection";
import {
- buildFhirDocumentBundle,
- GERMAN_ATC_DEFAULT_LOCALES,
-} from "@/lib/fhir/build-bundle";
-import {
- exportSelectionSchema,
- toDoctorReportPrefs,
-} from "@/lib/validations/health-record-export";
+ buildBundleJson,
+ buildPackageZip,
+ buildPdfBytes,
+ type ArtefactInputs,
+} from "@/lib/export/health-record-artefacts";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
- * Remember the practice / clinic name for the next report, so the user does
- * not retype it every time (`User.lastReportPracticeName`, the preference the
- * doctor-report contract has always promised).
+ * Remember what the owner chose, so the panel opens carrying it next time.
*
* Called only once the requested artefact has actually been produced: a
- * generation that throws must not overwrite the remembered name with a
- * half-typed one. `null` is a legitimate value — the user cleared the line and
- * the next cover should stay cleared. The value is the already-sanitised one
- * (`sanitisePracticeName`, 120-char cap), and the update names its single
- * field explicitly rather than spreading the parsed selection.
+ * generation that throws must not overwrite the remembered scope or a
+ * half-typed practice name. `null` is a legitimate practice name — the user
+ * cleared the line and the next cover should stay cleared. Both fields are
+ * named explicitly rather than spread from the parsed body.
*/
-async function rememberPracticeName(
+async function rememberChoices(
userId: string,
practiceName: string | null,
+ profile: {
+ v: 2;
+ leaves: string[];
+ format: "pdf" | "fhir" | "package";
+ rangeDays: number;
+ includeCharts: boolean;
+ },
) {
await prisma.user.update({
where: { id: userId },
- data: { lastReportPracticeName: practiceName },
+ data: {
+ lastReportPracticeName: practiceName,
+ reportSelectionJson: toJson(profile),
+ },
});
}
@@ -80,10 +87,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "export.health-record.build" } });
- // v1.18.0 B3 — the whole doctor-report / health-record surface is the
- // `doctorReport` module. Refuse with a 403 `module.disabled` envelope when
- // the account turned it off — even with a valid Bearer token. The settings
- // entry-point already hides client-side; this is the hard enforcement.
+ // The whole doctor-report surface is the `doctorReport` module. Refuse with
+ // a 403 `module.disabled` envelope when the account turned it off — even
+ // with a valid Bearer token.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
@@ -92,11 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
- // The selection payload is small and bounded — format, range,
- // section toggles, a few flags, an optional practice-name string.
- // 64 KB is far above any legitimate selection while still rejecting
- // a multi-megabyte body before it reaches `JSON.parse`. A DoS
- // ceiling, not a tight bound.
+ // The selection payload is small and bounded. 64 KB is far above any
+ // legitimate one while still rejecting a multi-megabyte body before it
+ // reaches `JSON.parse`. A DoS ceiling, not a tight bound.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -106,18 +110,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.success) {
return returnAllZodIssues(parsed.error);
}
- const selection = parsed.data;
+ const input = parsed.data;
- const range = normaliseDateRange(selection.range ?? undefined);
- const practiceName = sanitisePracticeName(selection.practiceName);
- const sections = toDoctorReportPrefs(selection.sections);
- const includeCharts = selection.includeCharts ?? true;
+ const minted = selectionFromRequest(input.selection);
+ if (!minted.ok) {
+ annotate({ meta: { unknownLeaves: minted.error.unknownLeaves } });
+ return apiError(
+ `Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
+ 422,
+ { errorCode: "export.selection.unknown_leaf" },
+ );
+ }
+ const selection = minted.selection;
- // Locale resolution, most-specific first: the explicit selection wins, then
- // the in-app `healthlog-locale` cookie (what the user actually chose in the
- // UI), and only then the browser's Accept-Language header. The header is the
- // weakest signal — a German user on an English-default browser would
- // otherwise get an English report.
+ const range = normaliseDateRange(input.range ?? undefined);
+ const practiceName = sanitisePracticeName(input.practiceName);
+ const includeCharts = input.includeCharts ?? true;
+
+ // Locale, most-specific first: the explicit selection wins, then the in-app
+ // cookie (what the user actually chose in the UI), and only then the
+ // browser's Accept-Language header — the weakest signal.
const cookieValue = request.cookies.get("healthlog-locale")?.value;
const cookieLocale = (locales as readonly string[]).includes(
cookieValue ?? "",
@@ -125,45 +137,34 @@ export const POST = apiHandler(async (request: NextRequest) => {
? (cookieValue as Locale)
: null;
const locale: Locale =
- selection.locale ??
+ input.locale ??
cookieLocale ??
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
- // BfArM ATC: an explicit selection flag wins; otherwise derive it from a
- // German-region locale. The WHO ATC coding is unaffected either way.
const germanAtc =
- selection.germanAtc ??
+ input.germanAtc ??
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
const [data, userTz, userRow, allergyRows, familyHistoryRows] =
await Promise.all([
- collectDoctorReportData(user.id, range, { practiceName, sections }),
+ collectDoctorReportData(user.id, range, selection, { practiceName }),
resolveUserTimezone(user.id),
prisma.user.findUnique({
where: { id: user.id },
- select: {
- insuranceNumberEncrypted: true,
- insightsCachedText: true,
- timeFormat: true,
- },
+ select: { insuranceNumberEncrypted: true, timeFormat: true },
}),
- // v1.25 (W-RECORDS) — structured records are always-available reference
- // data (not time-windowed), so they ride alongside the report rather
- // than through `collectDoctorReportData`. Owner-scoped, live rows only.
- //
- // Gated on the section toggles. These two reads fed ONLY the
- // FHIR bundle, and they ran unconditionally: `sections.allergies = false`
- // produced a PDF without an allergy section and a bundle carrying every
- // AllergyIntolerance, and in the `package` format both shipped in the
- // same zip. Zero DB read when off, matching how the aggregator treats
- // mood — deselected data should not be loaded, not merely dropped later.
- sections.allergies
+ // Structured records are always-available reference data (not
+ // time-windowed), so they ride alongside the report rather than through
+ // the aggregator. Owner-scoped, live rows only — and each read is gated
+ // on its own leaf, so a deselected section is not loaded, not merely
+ // dropped later.
+ selection.has("ALLERGIES")
? prisma.allergy.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
})
: Promise.resolve([]),
- sections.familyHistory
+ selection.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
@@ -171,16 +172,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
: Promise.resolve([]),
]);
- // FHIR records folded into every bundle format (fhir + package).
const fhirRecords = {
allergies: allergyRows.map(toAllergyDTO),
familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
};
- // Decrypt the KVNR fail-soft: a key-rotation gap on one row should
- // never 500 the export — the cover/identifier just omits the value.
+ // The KVNR is encrypted at rest and is its own leaf. It is neither read nor
+ // decrypted unless the insurance leaf was chosen. The decrypt is fail-soft:
+ // a key-rotation gap on one row omits the value rather than 500-ing the
+ // export.
let insuranceNumber: string | null = null;
- if (userRow?.insuranceNumberEncrypted) {
+ if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
} catch {
@@ -188,43 +190,53 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
- // AI summary is OUT of the clinical PDF by default — only included when
- // the user explicitly toggled `includeAiSummary` AND a cached briefing
- // text exists. Never reaches the FHIR bundle (not a clinical observation).
- const aiSummary =
- selection.includeAiSummary && userRow?.insightsCachedText
- ? extractAiSummaryText(userRow.insightsCachedText)
- : null;
-
const stamp = new Date().toISOString().slice(0, 10);
+ const { t } = getServerTranslator(locale);
+
+ const artefact: ArtefactInputs = {
+ data,
+ insuranceNumber,
+ records: fhirRecords,
+ germanAtc,
+ includeCharts,
+ locale,
+ userTz,
+ timeFormat: userRow?.timeFormat ?? "AUTO",
+ t,
+ };
+
+ const profile = {
+ v: 2 as const,
+ leaves: [...selection.leaves],
+ format: input.format,
+ rangeDays: Math.min(365, Math.max(1, range.days)),
+ includeCharts,
+ };
await auditLog("health-record.export", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
- format: selection.format,
+ format: input.format,
days: range.days,
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
charts: includeCharts,
- aiSummary: aiSummary !== null,
- // Section toggles only (booleans) — never the underlying values.
- sections,
+ // The chosen leaf ids — never the values behind them.
+ leaves: profile.leaves,
},
});
- if (selection.format === "fhir") {
- const bundle = buildFhirDocumentBundle(
- data,
- { insuranceNumber },
- undefined,
- { germanAtc },
- fhirRecords,
- );
- const json = JSON.stringify(bundle);
- await rememberPracticeName(user.id, practiceName);
+ if (input.format === "fhir") {
+ const json = buildBundleJson(artefact);
+ await rememberChoices(user.id, practiceName, profile);
annotate({
- meta: { format: "fhir", bytes: json.length, days: range.days },
+ meta: {
+ format: "fhir",
+ bytes: json.length,
+ days: range.days,
+ leafCount: profile.leaves.length,
+ },
});
return new NextResponse(json, {
status: 200,
@@ -236,23 +248,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
- const { t } = getServerTranslator(locale);
- const pdfBytes = renderDoctorReportPdfBytes(data, {
- t,
- locale,
- userTz,
- timeFormat: userRow?.timeFormat ?? "AUTO",
- insuranceNumber,
- includeCharts,
- aiSummary,
- });
- // Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the
- // artefact exists from here on.
- await rememberPracticeName(user.id, practiceName);
+ const pdfBytes = buildPdfBytes(artefact);
+ // Both remaining formats carry the rendered PDF, so the artefact exists from
+ // here on.
+ await rememberChoices(user.id, practiceName, profile);
- if (selection.format === "pdf") {
+ if (input.format === "pdf") {
annotate({
- meta: { format: "pdf", bytes: pdfBytes.byteLength, days: range.days },
+ meta: {
+ format: "pdf",
+ bytes: pdfBytes.byteLength,
+ days: range.days,
+ leafCount: profile.leaves.length,
+ },
});
const buffer = pdfBytes.slice().buffer;
return new NextResponse(buffer, {
@@ -266,25 +274,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
- // format === "package": one zip holding the PDF + FHIR Bundle + a README.
- const bundle = buildFhirDocumentBundle(
- data,
- { insuranceNumber },
- undefined,
- { germanAtc },
- fhirRecords,
- );
- const readme = t("doctorReport.packageReadme");
- const zipped = zipSync(
- {
- "report.pdf": pdfBytes,
- "bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
- "README.txt": strToU8(readme),
- },
- { level: 6 },
- );
+ const zipped = buildPackageZip(artefact, pdfBytes);
annotate({
- meta: { format: "package", bytes: zipped.byteLength, days: range.days },
+ meta: {
+ format: "package",
+ bytes: zipped.byteLength,
+ days: range.days,
+ leafCount: profile.leaves.length,
+ },
});
const zipBuffer = zipped.slice().buffer;
return new NextResponse(zipBuffer, {
@@ -297,26 +294,3 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
});
-
-/**
- * Extract plain summary text from the cached insights JSON blob. The cache
- * is provider-shaped JSON; we pull the human-readable summary field if
- * present, else fall back to a trimmed stringification. Always plain text
- * (no markdown rendering — the PDF prints React-free string children).
- */
-function extractAiSummaryText(raw: string): string | null {
- try {
- const parsed = JSON.parse(raw) as Record;
- const candidate =
- (typeof parsed.summary === "string" && parsed.summary) ||
- (typeof parsed.text === "string" && parsed.text) ||
- (typeof parsed.briefing === "string" && parsed.briefing) ||
- null;
- if (candidate) return candidate.trim().slice(0, 4000);
- return null;
- } catch {
- // Not JSON — treat as plain text.
- const trimmed = raw.trim();
- return trimmed.length > 0 ? trimmed.slice(0, 4000) : null;
- }
-}
diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts
index 1d39f38d3..8db19ac72 100644
--- a/src/app/api/meta/capabilities/route.ts
+++ b/src/app/api/meta/capabilities/route.ts
@@ -47,7 +47,10 @@ import {
FHIR_SEARCH_PARAMS,
} from "@/lib/fhir/rest";
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
-import { exportSectionsSchema } from "@/lib/validations/health-record-export";
+import {
+ ALL_LEAF_IDS,
+ REPORT_GROUP_ORDER,
+} from "@/lib/report-selection/catalogue";
export const dynamic = "force-dynamic";
@@ -113,17 +116,22 @@ export const GET = apiHandler(async () => {
operations: [FHIR_EVERYTHING_OPERATION],
searchParams: FHIR_SEARCH_PARAMS,
},
- // Clinician share-link surface (v1.11): a scoped, time-boxed, revocable
- // read-only link to the owner's record. Descriptor sourced from the
- // canonical share validation + export-section constants. The share serves
- // the rendered record view only; no `/api/fhir/*` route honours a share
- // token yet (they require an authenticated `fhir:read` Bearer), so the
- // share→FHIR face is not advertised as live.
+ // Clinician share-link surface: a scoped, time-boxed, revocable read-only
+ // link to the owner's record, sourced from the canonical share validation
+ // and the selection catalogue.
+ //
+ // `reportDownload` replaces the old `fhirApi: false`, which described a
+ // face that was never built. The record IS reachable in machine form now:
+ // as a download under the token, behind the same unlock the page uses,
+ // serving exactly the link's frozen selection. No bearer face, no new
+ // scope — `/api/fhir/*` still refuses a share token.
share: {
supported: true,
maxDays: SHARE_LINK_MAX_DAYS,
- fhirApi: false,
- sections: Object.keys(exportSectionsSchema.shape),
+ reportDownload: ["fhir", "pdf"],
+ selectionVersion: 2,
+ groups: REPORT_GROUP_ORDER,
+ leaves: ALL_LEAF_IDS,
},
});
});
diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts
index 8f8fb22f3..a98fd2fc8 100644
--- a/src/app/api/share-links/route.ts
+++ b/src/app/api/share-links/route.ts
@@ -28,8 +28,16 @@ import {
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
-import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
-import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
+import {
+ createShareLinkSchema,
+ SHARE_LINK_FORBIDDEN_LEAVES,
+} from "@/lib/validations/clinician-share-link";
+import {
+ EMPTY_REPORT_SELECTION,
+ reportSelectionSchema,
+ selectionFromRequest,
+ selectionToBlob,
+} from "@/lib/report-selection/selection";
import {
generatePassphrase,
hashPassphrase,
@@ -66,6 +74,10 @@ const shareLinkSummarySelect = {
rangeStart: true,
rangeEnd: true,
documentOnly: true,
+ // Read to answer one owner-facing question: does this link's frozen scope
+ // predate the selection model? Never returned — only the boolean derived
+ // from it is.
+ sectionsJson: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
@@ -75,6 +87,22 @@ const shareLinkSummarySelect = {
_count: { select: { documents: true } },
} as const;
+/**
+ * RETIRED, kept on the response only.
+ *
+ * These two fields described a scoped FHIR face for a share link that was
+ * never built. The request schema refuses them — sending either is a 422 —
+ * their columns are dropped, and nothing reads or honours them. They stay in
+ * the create and list responses, serving the constant `false` and `[]`,
+ * because the native client was given this response shape and a missing
+ * non-optional key fails a whole Swift decode. They come out once #70 in the
+ * native repository confirms the client does not decode them.
+ */
+const RETIRED_FHIR_SCOPE_FIELDS = {
+ allowFhirApi: false,
+ resourceTypes: [] as string[],
+} as const;
+
/** Project a stored row to the safe owner-facing shape (never the token). */
function toSummary(row: {
id: string;
@@ -82,6 +110,7 @@ function toSummary(row: {
rangeStart: Date;
rangeEnd: Date | null;
documentOnly: boolean;
+ sectionsJson: unknown;
passphraseHash: string | null;
expiresAt: Date;
createdAt: Date;
@@ -98,6 +127,14 @@ function toSummary(row: {
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
// gates on the column, not on "are all sections off?").
documentOnly: row.documentOnly,
+ // A record link whose frozen scope predates the selection model. The
+ // v1.32.37 migration revoked every one of them, because their scope was
+ // "everything except mood and cycle" — a scope nobody chose. The sharing
+ // list surfaces this so the owner sees why the link closed and can re-mint
+ // with the same label, window and expiry, stopped at the selection step.
+ needsReselection:
+ !row.documentOnly &&
+ !reportSelectionSchema.safeParse(row.sectionsJson).success,
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
protected: row.passphraseHash !== null,
@@ -134,16 +171,41 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const input = parsed.data;
- // v1.28.13 — a documents-only share carries NO report scope. Force the scope
- // column to empty here (not just at the client) so a document link can never
- // serve a single health metric, whatever the body tried to smuggle: an
- // explicit all-OFF prefs blob, distinct from the `{}` that means "full record
- // defaults". "Share this document" means the document, not the whole record.
+ // v1.28.13 — a documents-only share carries NO report scope. Force it empty
+ // here (not just at the client) so a document link can never serve a single
+ // health metric, whatever the body tried to smuggle. "Share this document"
+ // means the document, not the whole record.
const documentOnly = input.documentOnly === true;
- const sectionsJson = documentOnly
- ? EMPTY_DOCTOR_REPORT_PREFS
- : (input.sections ?? {});
- annotate({ meta: { documentOnly } });
+
+ let selection = EMPTY_REPORT_SELECTION;
+ if (!documentOnly && input.selection) {
+ const minted = selectionFromRequest(input.selection);
+ if (!minted.ok) {
+ return apiError(
+ `Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
+ 422,
+ { errorCode: "share-link.selection.unknown_leaf" },
+ );
+ }
+ selection = minted.selection;
+ }
+
+ // The insurance leaf is refused on a share link. The share view has never
+ // decrypted the insurance number, and refusing it here makes that structural
+ // rather than incidental: there is no path by which a link could carry it.
+ const forbidden = SHARE_LINK_FORBIDDEN_LEAVES.filter((leaf) =>
+ selection.has(leaf),
+ );
+ if (forbidden.length > 0) {
+ return apiError(`A share link cannot carry: ${forbidden.join(", ")}`, 422, {
+ errorCode: "share-link.selection.forbidden_leaf",
+ });
+ }
+
+ const sectionsJson = selectionToBlob(selection);
+ annotate({
+ meta: { documentOnly, leafCount: selection.leaves.length },
+ });
// Mint the 192-bit raw token, store only its HMAC hash.
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
@@ -217,6 +279,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess(
{
...toSummary(created),
+ ...RETIRED_FHIR_SCOPE_FIELDS,
token: rawToken,
passphrase: rawPassphrase,
shareUrl,
@@ -236,5 +299,10 @@ export const GET = apiHandler(async () => {
select: shareLinkSummarySelect,
});
- return apiSuccess({ shareLinks: rows.map(toSummary) });
+ return apiSuccess({
+ shareLinks: rows.map((row) => ({
+ ...toSummary(row),
+ ...RETIRED_FHIR_SCOPE_FIELDS,
+ })),
+ });
});
diff --git a/src/app/c/[token]/fhir/route.ts b/src/app/c/[token]/fhir/route.ts
new file mode 100644
index 000000000..82a25f1d6
--- /dev/null
+++ b/src/app/c/[token]/fhir/route.ts
@@ -0,0 +1,46 @@
+/**
+ * GET /c/{token}/fhir — the share link's record as an HL7 FHIR R4 document
+ * Bundle, as a download.
+ *
+ * Behind the same unlock cookie the page and the document route use, serving
+ * exactly the link's frozen selection. The insurance number is never carried:
+ * the insurance leaf is refused at share-link creation, so there is nothing to
+ * decrypt here and the identity block is built without it.
+ *
+ * The gate itself lives in `@/lib/clinician-share/report-download` so this
+ * route and the PDF beside it cannot drift apart.
+ */
+import { NextResponse } from "next/server";
+
+import { apiHandler } from "@/lib/api-handler";
+import { resolveShareReportDownload } from "@/lib/clinician-share/report-download";
+import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
+
+type RouteParams = { params: Promise<{ token: string }> };
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(
+ async (request: Request, { params }: RouteParams) => {
+ const { token } = await params;
+ const resolved = await resolveShareReportDownload(request, token, "fhir");
+ if (!resolved.ok) return resolved.response;
+
+ // No structured records ride a share bundle: allergies and family history
+ // reach the owner's own export through a separate read that this surface
+ // does not perform, so the bundle carries exactly the aggregated payload
+ // the page renders and nothing beside it.
+ const bundle = buildFhirDocumentBundle(resolved.report, {
+ insuranceNumber: null,
+ });
+ const stamp = new Date().toISOString().slice(0, 10);
+ return new NextResponse(JSON.stringify(bundle), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/fhir+json; charset=utf-8",
+ "Content-Disposition": `attachment; filename="healthlog-fhir-${stamp}.json"`,
+ "Cache-Control": "no-store",
+ },
+ });
+ },
+);
diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx
index 51582adcf..cfef75753 100644
--- a/src/app/c/[token]/page.tsx
+++ b/src/app/c/[token]/page.tsx
@@ -16,7 +16,7 @@
* string renders as escaped React text children (XSS posture).
*/
import type { Metadata } from "next";
-import { cookies, headers } from "next/headers";
+import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import {
@@ -29,8 +29,7 @@ import {
verifyUnlockValue,
} from "@/lib/clinician-share/unlock-cookie";
import { getServerTranslator } from "@/lib/i18n/server-translator";
-import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
-import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
+import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { ClinicianView } from "@/components/clinician/clinician-view";
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
@@ -45,20 +44,6 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
-async function resolveLocale(): Promise {
- try {
- const cookieStore = await cookies();
- const cookieLocale = cookieStore.get("healthlog-locale")?.value;
- if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
- return cookieLocale as Locale;
- }
- const headerList = await headers();
- return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
- } catch {
- return defaultLocale;
- }
-}
-
export default async function ClinicianSharePage({
params,
}: {
@@ -81,7 +66,7 @@ export default async function ClinicianSharePage({
unlockCookieName(gate.tokenHash),
)?.value;
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
- const locale = await resolveLocale();
+ const locale = await resolveShareViewLocale();
const { t } = getServerTranslator(locale);
// Resolve the gate copy on the server (it owns the locale) into a plain,
// serializable object. A `t` function cannot cross the RSC boundary into
@@ -107,12 +92,15 @@ export default async function ClinicianSharePage({
// Issue #490 — period/expiry dates render in the share OWNER's (patient's)
// profile timezone so they agree with the patient-tz aggregation behind the
// stats and with the doctor-report PDF (never the container's zone).
- const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] =
- await Promise.all([
- loadShareViewData(context),
- resolveLocale(),
- resolveUserTimezone(context.ownerUserId),
- ]);
+ const [
+ { report, selection, documents, documentOnly },
+ locale,
+ ownerTimezone,
+ ] = await Promise.all([
+ loadShareViewData(context),
+ resolveShareViewLocale(),
+ resolveUserTimezone(context.ownerUserId),
+ ]);
const { t } = getServerTranslator(locale);
return (
@@ -121,7 +109,7 @@ export default async function ClinicianSharePage({
label={context.label}
expiresAt={context.expiresAt.toISOString()}
report={report}
- sections={sections}
+ selection={selection}
documents={documents}
documentOnly={documentOnly}
token={token}
diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts
new file mode 100644
index 000000000..e2e4dd281
--- /dev/null
+++ b/src/app/c/[token]/report.pdf/route.ts
@@ -0,0 +1,51 @@
+/**
+ * GET /c/{token}/report.pdf — the share link's record as the clinical PDF.
+ *
+ * Behind the same unlock cookie the page and the document route use, serving
+ * exactly the link's frozen selection. Charts are embedded, matching the
+ * owner's own default; the practice-name cover line is whatever the owner
+ * froze onto the report, which for a share link is nothing.
+ *
+ * The gate itself lives in `@/lib/clinician-share/report-download` so this
+ * route and the bundle beside it cannot drift apart.
+ */
+import { NextResponse } from "next/server";
+
+import { apiHandler } from "@/lib/api-handler";
+import { resolveShareReportDownload } from "@/lib/clinician-share/report-download";
+import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
+
+type RouteParams = { params: Promise<{ token: string }> };
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(
+ async (request: Request, { params }: RouteParams) => {
+ const { token } = await params;
+ const resolved = await resolveShareReportDownload(request, token, "pdf");
+ if (!resolved.ok) return resolved.response;
+
+ // The recipient's locale, not the owner's: this document is being read by
+ // the practice, and the page above it already renders in the same one.
+ const locale = await resolveShareViewLocale();
+ const { t } = getServerTranslator(locale);
+ const bytes = renderDoctorReportPdfBytes(resolved.report, {
+ t,
+ locale,
+ insuranceNumber: null,
+ includeCharts: true,
+ });
+ const stamp = new Date().toISOString().slice(0, 10);
+ return new NextResponse(bytes.slice().buffer, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/pdf",
+ "Content-Disposition": `attachment; filename="healthlog-report-${stamp}.pdf"`,
+ "Content-Length": String(bytes.byteLength),
+ "Cache-Control": "no-store",
+ },
+ });
+ },
+);
diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx
index d6d583b1c..89e367317 100644
--- a/src/components/clinician/clinician-view.tsx
+++ b/src/components/clinician/clinician-view.tsx
@@ -1,26 +1,34 @@
/**
- * v1.11.0 — clinician-view presentation (Epic C, C5).
+ * The public clinician view.
*
* A pure server component: it receives an already-resolved, owner-scoped
* {@link DoctorReportData} plus a server-side translator and renders a
- * read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO
+ * read-only clinical summary. NO client hooks, NO session, NO AI or coach, NO
* markdown — every value renders as escaped React text.
*
- * Layout: provenance header → clinical vitals/labs → medications + adherence →
- * a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
- * clinical assessment / not a diagnosis" disclaimer.
+ * Layout: provenance header (with the two machine-format downloads) → the
+ * measurement groups in selection order → glucose → medications and adherence
+ * → a FENCED, muted wellness card carrying the load-bearing "descriptive, not
+ * a clinical assessment" disclaimer → the attached documents.
+ *
+ * The section components live in `./report-sections`, the document list in
+ * `./documents-list`, the downloads in `./download-actions`.
*/
-import { Download } from "lucide-react";
-
-import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
-import { formatBytes } from "@/components/documents/vault-utils";
-import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
-import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
-import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
-import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
+import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
+import type { ReportSelection } from "@/lib/report-selection/selection";
+import { DocumentEntry } from "./documents-list";
+import { ShareDownloadActions } from "./download-actions";
+import {
+ GlucoseSection,
+ MeasurementGroups,
+ MedicationsSection,
+ Section,
+ StatRow,
+ WellnessSection,
+} from "./report-sections";
type Translate = (
key: string,
@@ -34,22 +42,22 @@ interface ClinicianViewProps {
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
expiresAt: string;
/**
- * The owner-scoped report payload, or `null` for a documents-only share
- * (no report section enabled). When `null` NO health metric is rendered —
- * only the header, disclaimer, and the attached documents.
+ * The owner-scoped report payload, or `null` for a documents-only share.
+ * When `null` NO health metric is rendered — only the header, the
+ * disclaimer, and the attached documents.
*/
report: DoctorReportData | null;
- sections: DoctorReportPrefs;
+ /** The link's frozen selection, resolved. */
+ selection: ReportSelection;
/**
- * v1.28.13 — a documents-only link. Hides the reporting-period line (there is
- * no report) and, together with a `null` report, keeps every health section
- * off the page. "Share this document" means the document, not the record.
+ * A documents-only link. Hides the reporting-period line (there is no
+ * report) and, together with a `null` report, keeps every health section off
+ * the page.
*/
documentOnly?: boolean;
/**
* The frozen document set on this link (metadata only — never bytes). Each
- * entry points at the token-scoped serve route (`/c//d/`), the
- * one decrypt path. Empty when the owner shared no documents.
+ * entry points at the token-scoped serve route, the one decrypt path.
*/
documents?: ShareViewDocument[];
/** The raw share token from the path — used to build serve-route URLs. */
@@ -57,154 +65,19 @@ interface ClinicianViewProps {
/** Viewer locale (byte formatting only). Defaults to English. */
locale?: Locale;
/**
- * Issue #490 — the share OWNER's (patient's) profile timezone. Period
- * start/end and the expiry date render in this zone so the dates agree
- * with the patient-tz aggregation behind the stats (and with the
- * doctor-report PDF). Pre-fix the dates rendered via a bare
- * `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
- * the patient's nor the viewer's, with US-English field order regardless
- * of the resolved page locale.
+ * Issue #490 — the share OWNER's profile timezone. Period start/end and the
+ * expiry date render in this zone so the dates agree with the patient-tz
+ * aggregation behind the stats (and with the doctor-report PDF).
*/
timezone?: string;
}
-/** Human-readable display per persisted wellness-score type (i18n key suffix). */
-const WELLNESS_KEY: Record = {
- RECOVERY_SCORE: "recovery",
- STRESS_SCORE: "stress",
- STRAIN_SCORE: "strain",
-};
-
-/**
- * Localised label for a measurement-type enum, reusing the doctor-report type
- * key map so the clinician view reads in the viewer's locale alongside the rest
- * of the page. A type with no key falls back to a humanised enum form
- * (`BLOOD_PRESSURE_SYS` → "Blood pressure sys").
- */
-function typeLabel(type: string, t: Translate): string {
- const key = DOCTOR_REPORT_TYPE_LABEL_KEYS[type];
- if (key) return t(key);
- const lower = type.replace(/_/g, " ").toLowerCase();
- return lower.charAt(0).toUpperCase() + lower.slice(1);
-}
-
-/** Render a single labelled stat row. */
-function StatRow({ label, value }: { label: string; value: string }) {
- return (
-
- {children}
-
- );
-}
-
-/**
- * One shared document. Class A (inline) renders a preview straight at the
- * token-scoped serve route — an `` for raster images, a framed PDF
- * otherwise; the proxy's share-serve CSP (`default-src 'none';
- * frame-ancestors 'self'`) fences the framed context. Class B (attachment)
- * offers a plain download link — the serve route answers it as an opaque
- * `application/octet-stream` attachment. No bytes reach this component; only
- * the metadata and the URL.
- */
-function DocumentEntry({
- t,
- doc,
- token,
- locale,
-}: {
- t: Translate;
- doc: ShareViewDocument;
- token: string;
- locale: string;
-}) {
- const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
- const title = doc.title ?? t("clinicianView.documents.untitled");
- const Icon =
- DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
- const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
- Boolean,
- ) as string[];
- const isImage = doc.mimeType.startsWith("image/");
- const isInline = doc.servingClass === "inline";
-
- return (
-
-
-
- {Icon ? (
-
- ) : null}
-
-
{title}
- {meta.length > 0 ? (
-
- {meta.join(" · ")}
-
- ) : null}
-
-
- {/* Download is always available (inline or attachment) so the recipient
- can keep a copy; the anchor hits the same token-scoped serve route. */}
-
-
- {t("clinicianView.documents.download")}
-
-
-
- {isInline ? (
-
- {isImage ? (
- // The served image is the frozen original with EXIF/GPS stripped on
- // egress; it renders inline within the Class A carve-out.
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- )}
-
- ) : (
-
- {t("clinicianView.documents.attachmentHint")}
-
- )}
-
- );
-}
-
export function ClinicianView({
t,
label,
expiresAt,
report,
- sections,
+ selection,
documents = [],
documentOnly = false,
token = "",
@@ -217,22 +90,6 @@ export function ClinicianView({
const fmtDate = (iso: string) => fmt.date(new Date(iso));
const fmtNum = (n: number) => Math.round(n * 100) / 100;
- // A documents-only share carries no report — the aggregator is never called
- // server-side, so `report` is null. Every health-derived list below stays
- // empty by construction and only the attached documents render.
- const measurementEntries = report
- ? Object.entries(report.stats).filter(([, s]) => s.count > 0)
- : [];
- const glucoseEntries = report
- ? Object.entries(report.glucoseStats).filter(([, s]) => s.count > 0)
- : [];
- const complianceEntries =
- report && sections.compliance
- ? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
- : [];
- const wellness = report?.wellnessScores?.filter((s) => s.count > 0) ?? [];
- const medications = report?.medications ?? [];
-
return (
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
-
+
+
+
+ >
) : null}
{/* ── Shared documents ────────────────────────────────────── */}
diff --git a/src/components/clinician/documents-list.tsx b/src/components/clinician/documents-list.tsx
new file mode 100644
index 000000000..b5850b2dd
--- /dev/null
+++ b/src/components/clinician/documents-list.tsx
@@ -0,0 +1,98 @@
+/**
+ * The frozen document set on a share link, as a list of entries.
+ *
+ * No bytes reach this component: each entry points at the token-scoped serve
+ * route, which is the one decrypt path. Class A (inline) renders a preview
+ * straight at that route — an `` for raster images, a framed PDF
+ * otherwise, fenced by the proxy's share-serve CSP. Class B is download-only.
+ *
+ * Split out of `clinician-view.tsx` with the selection rework.
+ */
+import { Download } from "lucide-react";
+
+import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
+import { formatBytes } from "@/components/documents/vault-utils";
+import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
+import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
+
+type Translate = (
+ key: string,
+ vars?: Record,
+) => string;
+
+export function DocumentEntry({
+ t,
+ doc,
+ token,
+ locale,
+}: {
+ t: Translate;
+ doc: ShareViewDocument;
+ token: string;
+ locale: string;
+}) {
+ const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
+ const title = doc.title ?? t("clinicianView.documents.untitled");
+ const Icon =
+ DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
+ const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
+ Boolean,
+ ) as string[];
+ const isImage = doc.mimeType.startsWith("image/");
+ const isInline = doc.servingClass === "inline";
+
+ return (
+
+
+
+ {Icon ? (
+
+ ) : null}
+
+
{title}
+ {meta.length > 0 ? (
+
+ {meta.join(" · ")}
+
+ ) : null}
+
+
+ {/* Download is always available (inline or attachment) so the recipient
+ can keep a copy; the anchor hits the same token-scoped serve route. */}
+
+
+ {t("clinicianView.documents.download")}
+
+
+
+ {isInline ? (
+
+ {isImage ? (
+ // The served image is the frozen original with EXIF/GPS stripped on
+ // egress; it renders inline within the Class A carve-out.
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ {t("clinicianView.documents.attachmentHint")}
+
+ )}
+
+ );
+}
diff --git a/src/components/clinician/download-actions.tsx b/src/components/clinician/download-actions.tsx
new file mode 100644
index 000000000..137282fb6
--- /dev/null
+++ b/src/components/clinician/download-actions.tsx
@@ -0,0 +1,46 @@
+/**
+ * The two machine-format downloads on a share link.
+ *
+ * A practice opens the link, unlocks it, and saves the file into their system.
+ * These serve exactly the link's frozen selection — the same object the page
+ * above renders from — behind the same unlock cookie the document route
+ * enforces. There is no bearer face and no new scope.
+ *
+ * Rendered only for a link that actually carries a record; a documents-only
+ * share has nothing to download here.
+ */
+import { Download } from "lucide-react";
+
+type Translate = (
+ key: string,
+ vars?: Record,
+) => string;
+
+export function ShareDownloadActions({
+ t,
+ token,
+}: {
+ t: Translate;
+ token: string;
+}) {
+ const base = `/c/${encodeURIComponent(token)}`;
+ const actions = [
+ { href: `${base}/report.pdf`, label: t("clinicianView.downloadPdf") },
+ { href: `${base}/fhir`, label: t("clinicianView.downloadFhir") },
+ ];
+ return (
+
+ );
+}
diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx
new file mode 100644
index 000000000..60b4008cb
--- /dev/null
+++ b/src/components/clinician/report-sections.tsx
@@ -0,0 +1,232 @@
+/**
+ * The health sections of the clinician view, grouped the way the owner chose
+ * them.
+ *
+ * The measurement list used to be a flat `Object.entries(report.stats)` with a
+ * humanised-enum fallback for any type the ten-entry label map missed, so a
+ * clinician read rows called "Phq9 score" and "Audio exposure headphone" in
+ * one undifferentiated column. It now renders in the same twelve groups the
+ * selection panel shows, with the real localised labels, so the page and the
+ * PDF describe the record in the same order.
+ *
+ * A pure server component: no client hooks, no session, no markdown — every
+ * value renders as escaped React text.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
+import {
+ isStructuredLeafId,
+ REPORT_GROUPS,
+} from "@/lib/report-selection/catalogue";
+import type { ReportSelection } from "@/lib/report-selection/selection";
+
+type Translate = (
+ key: string,
+ vars?: Record,
+) => string;
+
+/** Human-readable display per persisted wellness-score type (i18n key suffix). */
+const WELLNESS_KEY: Record = {
+ RECOVERY_SCORE: "recovery",
+ STRESS_SCORE: "stress",
+ STRAIN_SCORE: "strain",
+};
+
+/** Render a single labelled stat row. */
+export function StatRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The measurement groups that carry data, in selection order. The aggregator
+ * has already applied the selection, so a type missing from `report.stats` is
+ * one that was withheld or has no reading.
+ */
+export function MeasurementGroups({
+ t,
+ report,
+ fmtNum,
+}: {
+ t: Translate;
+ report: DoctorReportData;
+ fmtNum: (n: number) => number;
+}) {
+ const groups = REPORT_GROUPS.map((group) => ({
+ labelKey: group.labelKey,
+ rows: group.leaves
+ .filter((leaf): leaf is MeasurementType => !isStructuredLeafId(leaf))
+ .map((type) => ({ type, stat: report.stats[type] }))
+ .filter((row) => row.stat !== undefined && row.stat.count > 0),
+ })).filter((group) => group.rows.length > 0);
+
+ if (groups.length === 0) return null;
+
+ return (
+ <>
+ {groups.map((group) => (
+
+ {group.rows.map(({ type, stat }) => (
+
+ ))}
+
+ ))}
+ >
+ );
+}
+
+/** Per-context glucose stats. Present only when the glucose panel was chosen. */
+export function GlucoseSection({
+ t,
+ report,
+ fmtNum,
+}: {
+ t: Translate;
+ report: DoctorReportData;
+ fmtNum: (n: number) => number;
+}) {
+ const entries = Object.entries(report.glucoseStats).filter(
+ ([, s]) => s.count > 0,
+ );
+ if (entries.length === 0) return null;
+ return (
+
+ {entries.map(([ctx, s]) => (
+
+ ))}
+
+ );
+}
+
+/** The medication list with the adherence rate beside each drug. */
+export function MedicationsSection({
+ t,
+ report,
+ selection,
+}: {
+ t: Translate;
+ report: DoctorReportData;
+ selection: ReportSelection;
+}) {
+ const medications = report.medications ?? [];
+ const complianceOn = selection.has("MEDICATION_COMPLIANCE");
+ const complianceEntries = complianceOn
+ ? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
+ : [];
+ if (medications.length === 0 && complianceEntries.length === 0) return null;
+
+ return (
+
+ {medications.map((med) => {
+ const comp = report.compliance[med.name];
+ const rate =
+ complianceOn && comp && comp.total > 0
+ ? `${Math.round((comp.taken / comp.total) * 100)}%`
+ : null;
+ return (
+
+ );
+ })}
+ {complianceEntries
+ .filter(([name]) => !medications.some((m) => m.name === name))
+ .map(([name, c]) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * The fenced wellness card. Descriptive composites, not clinical assessments —
+ * the disclaimer is load-bearing and sits above the numbers, not under them.
+ */
+export function WellnessSection({
+ t,
+ report,
+ fmtNum,
+}: {
+ t: Translate;
+ report: DoctorReportData;
+ fmtNum: (n: number) => number;
+}) {
+ const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
+ if (wellness.length === 0) return null;
+ return (
+
+
+ {t("clinicianView.wellness.title")}
+
+
+ {t("clinicianView.wellness.disclaimer")}
+
+
+ {wellness.map((s) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/lib/clinician-share/report-download.ts b/src/lib/clinician-share/report-download.ts
new file mode 100644
index 000000000..b341a1afe
--- /dev/null
+++ b/src/lib/clinician-share/report-download.ts
@@ -0,0 +1,132 @@
+/**
+ * The shared gate behind a share link's machine-format downloads.
+ *
+ * A practice opens the link, unlocks it, and saves the file into their system.
+ * That is the thing a bearer-authenticated API was supposed to serve and never
+ * did, so this serves it as a download instead: no new credential type, no new
+ * scope, no mint path that does not exist.
+ *
+ * The gate is the document route's, step for step — the same live check, the
+ * same token-scoped unlock cookie, a rate limit before any aggregation, the
+ * same counter bump, the same blunt 404 for every miss class. It differs in
+ * one number: generating a bundle is far more expensive than serving a stored
+ * blob, so it gets its own smaller bucket rather than sharing the document
+ * route's budget.
+ *
+ * Scope cannot exceed the page's: both resolve the SAME frozen `sectionsJson`
+ * through `selectionFromStoredBlob`, and the insurance leaf is refused at
+ * share-link creation, so the insurance number cannot reach this path at all.
+ */
+import { cookies } from "next/headers";
+
+import { apiError } from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { getClientIp } from "@/lib/api-response";
+import {
+ resolveShareGateState,
+ resolveShareToken,
+} from "@/lib/clinician-share/resolve-share-token";
+import {
+ unlockCookieName,
+ verifyUnlockValue,
+} from "@/lib/clinician-share/unlock-cookie";
+import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import type { ReportSelection } from "@/lib/report-selection/selection";
+
+/**
+ * 20 per hour per link. A practice saves the record once, maybe twice; a
+ * leaked token cannot turn the aggregator into a grinder. Deliberately a
+ * separate bucket from the document route's 300/h and the unlock route's
+ * 10/10min, per the `:${identifier}` convention.
+ */
+const REPORT_DOWNLOAD_LIMIT_PER_HOUR = 20;
+const REPORT_DOWNLOAD_WINDOW_MS = 60 * 60 * 1000;
+
+/** One blunt 404 for every miss class — a probe distinguishes nothing. */
+function notFound() {
+ return apiError("Not found", 404, {
+ errorCode: "share-link.report.notFound",
+ });
+}
+
+export type ReportDownloadResult =
+ | { ok: true; report: DoctorReportData; selection: ReportSelection }
+ | { ok: false; response: Response };
+
+/**
+ * Run the gate and produce the link's report payload, or the response to
+ * return instead. A documents-only link — or one whose frozen scope this build
+ * cannot read — has no record to download and gets the same flat 404.
+ */
+export async function resolveShareReportDownload(
+ request: Request,
+ token: string,
+ format: "fhir" | "pdf",
+): Promise {
+ annotate({ action: { name: "share.report.download" } });
+
+ // 1. Live-gate check first (no counter bump). Malformed / unknown / revoked
+ // / expired all collapse to the same flat 404.
+ const gate = await resolveShareGateState(token);
+ if (!gate) return { ok: false, response: notFound() };
+
+ // 2. Passphrase gate: a protected link serves nothing until the recipient
+ // holds the short-lived, token-scoped unlock cookie. This route lives
+ // under `/c/` so the browser sends that cookie automatically.
+ if (gate.passphraseHash !== null) {
+ const cookieStore = await cookies();
+ const unlockValue = cookieStore.get(
+ unlockCookieName(gate.tokenHash),
+ )?.value;
+ if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
+ return { ok: false, response: notFound() };
+ }
+ }
+
+ // 3. Per-link rate limit BEFORE any aggregation.
+ const rl = await checkRateLimit(
+ `report-download:${gate.tokenHash}`,
+ REPORT_DOWNLOAD_LIMIT_PER_HOUR,
+ REPORT_DOWNLOAD_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ const response = apiError("Too many requests. Try again later.", 429, {
+ errorCode: "share-link.report.rateLimited",
+ });
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ response.headers.set(k, v);
+ }
+ return { ok: false, response };
+ }
+
+ // 4. Resolve the full share context. Revocation and expiry are re-enforced
+ // here and the access counters are bumped by the same path that bumps
+ // them for the page.
+ const context = await resolveShareToken(token);
+ if (!context) return { ok: false, response: notFound() };
+
+ // 5. The link's own frozen scope, resolved exactly as the page resolves it,
+ // so the download can never be wider than what the page renders.
+ const view = await loadShareViewData(context);
+ if (view.documentOnly || !view.report) {
+ return { ok: false, response: notFound() };
+ }
+
+ await auditLog("share-link.report.download", {
+ userId: context.ownerUserId,
+ ipAddress: getClientIp(request),
+ details: {
+ shareLinkId: context.shareLinkId,
+ format,
+ leafCount: view.selection.leaves.length,
+ },
+ });
+ annotate({
+ meta: { format, leafCount: view.selection.leaves.length },
+ });
+
+ return { ok: true, report: view.report, selection: view.selection };
+}
diff --git a/src/lib/clinician-share/request-locale.ts b/src/lib/clinician-share/request-locale.ts
new file mode 100644
index 000000000..3204be4cd
--- /dev/null
+++ b/src/lib/clinician-share/request-locale.ts
@@ -0,0 +1,26 @@
+/**
+ * The viewer's locale on a share surface.
+ *
+ * A share link has no session, so the locale comes from the in-app cookie the
+ * recipient's own browser may carry, then from Accept-Language, then from the
+ * default. Extracted from the share page so the PDF download beside it renders
+ * in the same language as the page it was downloaded from.
+ */
+import { cookies, headers } from "next/headers";
+
+import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
+import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
+
+export async function resolveShareViewLocale(): Promise {
+ try {
+ const cookieStore = await cookies();
+ const cookieLocale = cookieStore.get("healthlog-locale")?.value;
+ if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
+ return cookieLocale as Locale;
+ }
+ const headerList = await headers();
+ return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
+ } catch {
+ return defaultLocale;
+ }
+}
diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts
index 88cc3676c..b6f994412 100644
--- a/src/lib/clinician-share/share-view-data.ts
+++ b/src/lib/clinician-share/share-view-data.ts
@@ -22,10 +22,10 @@ import { isModuleEnabled } from "@/lib/modules/gate";
import { servingClassFor } from "@/lib/documents/upload-policy";
import type { DocumentServingClass } from "@/lib/documents/upload-policy";
import {
- hasAnyReportSection,
- type DoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
-import { resolveStoredReportSections } from "@/lib/validations/health-record-export";
+ isEmptySelection,
+ selectionFromStoredBlob,
+ type ReportSelection,
+} from "@/lib/report-selection/selection";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
/**
@@ -59,8 +59,8 @@ export interface ShareViewData {
* the recipient sees only the attached documents.
*/
report: DoctorReportData | null;
- /** The resolved section toggles (mood opt-in, defaults otherwise). */
- sections: DoctorReportPrefs;
+ /** The link's frozen selection, resolved. Empty when it carries no scope. */
+ selection: ReportSelection;
/** v1.28 — the hand-picked documents on this link (metadata only). */
documents: ShareViewDocument[];
/**
@@ -94,12 +94,11 @@ function frozenRange(context: ShareContext): DoctorReportRange {
export async function loadShareViewData(
context: ShareContext,
): Promise {
- // The stored `sectionsJson` may be the GROUPED export shape (what the create
- // schema accepts) or the FLAT doctor-report shape (documents-only / legacy).
- // `resolveStoredReportSections` folds either down correctly — reading a
- // grouped blob through the flat parser would silently drop every grouped
- // toggle and default the section back ON, re-widening a scope the owner froze.
- const sections = resolveStoredReportSections(context.sectionsJson);
+ // The link's frozen scope. A blob this build cannot read — a legacy shape, a
+ // hand-edited row — resolves to the EMPTY selection, so the link serves
+ // nothing rather than defaulting open. Every such link was revoked by the
+ // v1.32.37 migration; this is the second lock behind that.
+ const selection = selectionFromStoredBlob(context.sectionsJson);
const range = frozenRange(context);
// A documents-only share never aggregates — no health metric is read from the
@@ -128,18 +127,16 @@ export async function loadShareViewData(
"doctorReport",
);
const documentOnly =
- context.documentOnly ||
- !hasAnyReportSection(sections) ||
- !reportModuleEnabled;
+ context.documentOnly || isEmptySelection(selection) || !reportModuleEnabled;
const [report, documents] = await Promise.all([
documentOnly
? Promise.resolve(null)
- : collectDoctorReportData(context.ownerUserId, range, { sections }),
+ : collectDoctorReportData(context.ownerUserId, range, selection),
loadShareDocuments(context),
]);
- return { report, sections, documents, documentOnly };
+ return { report, selection, documents, documentOnly };
}
/**
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index f5085a054..03d83687b 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -1,1428 +1,14 @@
/**
- * Server-side aggregator for doctor-report data.
+ * The doctor-report aggregator's public surface.
*
- * Single source of truth for the aggregated payload consumed by both
- * `/api/doctor-report` (JSON, client renders PDF) and
- * `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
- * structurally identical so visual parity between client- and server-rendered
- * PDFs is guaranteed by construction, not by drift-prone copy-paste.
+ * The work itself lives under `src/lib/doctor-report/` — the selection gate,
+ * the dense-bucket query, the per-type series, the glucose panel, the
+ * medication ledger, the structured clinical records and the orchestrator that
+ * composes them. It was one 1,400-line function doing a dozen jobs; a gate
+ * buried in the middle of that is a gate nobody re-reads.
*
- * The data shapes live in `doctor-report-types.ts` and the pure helpers in
- * `doctor-report-helpers.ts`; both are re-exported here so call sites keep a
- * single import surface.
+ * This module stays the single import path so no call site had to change.
*/
-
-import { prisma } from "@/lib/db";
-import {
- getEffectiveRange,
- type ThresholdOverridesJson,
-} from "@/lib/analytics/effective-range";
-import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
-import {
- computeGlucoseClinicalMetrics,
- CV_INSTABILITY_THRESHOLD,
- estimatedA1c,
- gmi,
- type GlucoseClinicalMetrics,
-} from "@/lib/analytics/glucose-metrics";
-import type {
- GlucoseContext,
- MeasurementSource,
- MeasurementType,
-} from "@/generated/prisma/client";
-import { getEvent } from "@/lib/logging/context";
-import { readNote } from "@/lib/crypto/note-cipher";
-import {
- reconstructSleepNights,
- type SleepStageRow,
-} from "@/lib/analytics/sleep-night";
-import {
- DEFAULT_DOCTOR_REPORT_PREFS,
- type DoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
-import { buildCycleExportSummary } from "@/lib/cycle/export-data";
-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,
- type CollectDoctorReportOptions,
- type DoctorReportData,
- type DoctorReportMood,
- type DoctorReportRange,
- type DoctorReportStats,
-} from "./doctor-report-types";
-import {
- buildLedgerCompliance,
- collapseMeasurementsToCanonical,
- decryptAllergyReaction,
- minMaxOf,
- resolveMaxMedicationAdministrations,
- sanitisePracticeName,
- summariseCanonicalRecovery,
-} from "./doctor-report-helpers";
-
export * from "./doctor-report-types";
export * from "./doctor-report-helpers";
-
-const GLUCOSE_CONTEXTS: GlucoseContext[] = [
- "FASTING",
- "POSTPRANDIAL",
- "RANDOM",
- "BEDTIME",
-];
-
-const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
-
-interface DenseMeasurementBucket {
- bucketStart: Date;
- type: MeasurementType;
- source: MeasurementSource;
- deviceType: string | null;
- glucoseContext: GlucoseContext | null;
- count: number;
- sumValue: number;
- minValue: number;
- maxValue: number;
- latestAt: Date;
- latestValue: number;
- clinicalCount: number;
- clinicalSum: number | null;
- clinicalSumSquares: number | null;
- clinicalFirstAt: Date | null;
- clinicalLastAt: Date | null;
- clinicalTirCount: number;
- clinicalTbr1Count: number;
- clinicalTbr2Count: number;
- clinicalTar1Count: number;
- clinicalTar2Count: number;
- clinicalLowRiskSum: number | null;
- clinicalHighRiskSum: number | null;
-}
-
-interface DenseMeasurementSummary {
- byType: Record>;
- stats: Record;
- glucoseStats: Record;
- glucoseClinical: GlucoseClinicalMetrics;
-}
-
-interface DenseStatsAccumulator {
- count: number;
- sum: number;
- min: number;
- max: number;
- latestAt: Date;
- latest: number;
-}
-
-function emptyGlucoseClinical(windowDays: number): GlucoseClinicalMetrics {
- return {
- stillLearning: true,
-
- stillLearningReason: `No glucose readings in the last ${windowDays} days.`,
- windowDays,
- actualSpanDays: 0,
- readingCount: 0,
- meanMgdl: null,
- distribution: null,
- gmi: null,
- estimatedA1c: null,
- variability: null,
- advanced: null,
- isSpotEstimate: true,
- };
-}
-
-function glucoseClinicalFromBuckets(
- buckets: readonly DenseMeasurementBucket[],
- windowDays: number,
-): GlucoseClinicalMetrics {
- let count = 0;
- let sum = 0;
- let sumSquares = 0;
- let firstAt: Date | null = null;
- let lastAt: Date | null = null;
- let tir = 0;
- let tbr1 = 0;
- let tbr2 = 0;
- let tar1 = 0;
- let tar2 = 0;
- let lowRisk = 0;
- let highRisk = 0;
-
- for (const bucket of buckets) {
- if (bucket.type !== "BLOOD_GLUCOSE" || bucket.clinicalCount === 0) {
- continue;
- }
- count += bucket.clinicalCount;
- sum += bucket.clinicalSum ?? 0;
- sumSquares += bucket.clinicalSumSquares ?? 0;
- tir += bucket.clinicalTirCount;
- tbr1 += bucket.clinicalTbr1Count;
- tbr2 += bucket.clinicalTbr2Count;
- tar1 += bucket.clinicalTar1Count;
- tar2 += bucket.clinicalTar2Count;
- lowRisk += bucket.clinicalLowRiskSum ?? 0;
- highRisk += bucket.clinicalHighRiskSum ?? 0;
- if (
- bucket.clinicalFirstAt &&
- (!firstAt || bucket.clinicalFirstAt < firstAt)
- ) {
- firstAt = bucket.clinicalFirstAt;
- }
- if (bucket.clinicalLastAt && (!lastAt || bucket.clinicalLastAt > lastAt)) {
- lastAt = bucket.clinicalLastAt;
- }
- }
-
- if (count === 0 || !firstAt || !lastAt) {
- return emptyGlucoseClinical(windowDays);
- }
-
- const meanMgdl = sum / count;
- const actualSpanDays =
- (lastAt.getTime() - firstAt.getTime()) / (24 * 60 * 60 * 1000);
- const variance =
- count >= 2
- ? Math.max(0, (sumSquares - (sum * sum) / count) / (count - 1))
- : null;
- const sd = variance === null ? null : Math.sqrt(variance);
- const cv = sd === null || meanMgdl === 0 ? null : (sd / meanMgdl) * 100;
- const fraction = (n: number) => n / count;
- const minutes = (n: number) => fraction(n) * 1440;
- const roundedSpan = Math.max(0, Math.round(actualSpanDays));
- let stillLearningReason: string | null = null;
- if (count < 14) {
- stillLearningReason = `Still learning — ${count} reading${
- count === 1 ? "" : "s"
- } over ${roundedSpan} day${
- roundedSpan === 1 ? "" : "s"
- }; at least 14 are needed for a meaningful estimate.`;
- } else if (actualSpanDays < 7) {
- stillLearningReason = `Still learning — readings span only ${roundedSpan} day${
- roundedSpan === 1 ? "" : "s"
- }; at least 7 days of coverage are needed.`;
- }
-
- return {
- stillLearning: stillLearningReason !== null,
- stillLearningReason,
- windowDays,
- actualSpanDays,
- readingCount: count,
- meanMgdl,
- distribution: {
- tir: fraction(tir),
- tbrLevel1: fraction(tbr1),
- tbrLevel2: fraction(tbr2),
- tarLevel1: fraction(tar1),
- tarLevel2: fraction(tar2),
- minutesEquivalent: {
- tir: minutes(tir),
- tbrLevel1: minutes(tbr1),
- tbrLevel2: minutes(tbr2),
- tarLevel1: minutes(tar1),
- tarLevel2: minutes(tar2),
- },
- },
- gmi: gmi(meanMgdl),
- estimatedA1c: estimatedA1c(meanMgdl),
- variability:
- sd === null || cv === null
- ? null
- : { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD },
- advanced: {
- jIndex: sd === null ? null : 0.001 * (meanMgdl + sd) ** 2,
- lbgi: lowRisk / count,
- hbgi: highRisk / count,
- },
- isSpotEstimate: count / Math.max(actualSpanDays, 1) < 24,
- };
-}
-
-function summariseDenseBuckets(
- rows: readonly DenseMeasurementBucket[],
- timezone: string,
- sourcePriorityJson: unknown,
- windowDays: number,
-): DenseMeasurementSummary {
- const canonical = collapseMeasurementsToCanonical(
- rows.map((row) => ({
- ...row,
- value: row.sumValue / row.count,
- measuredAt: row.bucketStart,
- })),
- timezone,
- sourcePriorityJson,
- );
- const byType: DenseMeasurementSummary["byType"] = {};
- const statsAcc = new Map();
- const glucoseStatsAcc = new Map();
-
- const byTypeDay = new Map<
- string,
- {
- type: string;
- bucketStart: Date;
- count: number;
- sum: number;
- min: number;
- max: number;
- latestAt: Date;
- latest: number;
- }
- >();
- for (const row of canonical) {
- const key = `${row.type}:${row.bucketStart.toISOString()}`;
- const current = byTypeDay.get(key);
- if (!current) {
- byTypeDay.set(key, {
- type: row.type,
- bucketStart: row.bucketStart,
- count: row.count,
- sum: row.sumValue,
- min: row.minValue,
- max: row.maxValue,
- latestAt: row.latestAt,
- latest: row.latestValue,
- });
- continue;
- }
- current.count += row.count;
- current.sum += row.sumValue;
- current.min = Math.min(current.min, row.minValue);
- current.max = Math.max(current.max, row.maxValue);
- if (row.latestAt > current.latestAt) {
- current.latestAt = row.latestAt;
- current.latest = row.latestValue;
- }
- }
-
- const accumulate = (
- target: Map,
- key: string,
- row: DenseStatsAccumulator,
- ) => {
- const current = target.get(key);
- if (!current) {
- target.set(key, { ...row });
- return;
- }
- current.count += row.count;
- current.sum += row.sum;
- current.min = Math.min(current.min, row.min);
- current.max = Math.max(current.max, row.max);
- if (row.latestAt > current.latestAt) {
- current.latestAt = row.latestAt;
- current.latest = row.latest;
- }
- };
-
- for (const row of byTypeDay.values()) {
- (byType[row.type] ??= []).push({
- value: row.sum / row.count,
- measuredAt: row.bucketStart.toISOString(),
- });
- accumulate(statsAcc, row.type, row);
- }
-
- for (const row of canonical) {
- if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
- accumulate(glucoseStatsAcc, row.glucoseContext, {
- count: row.count,
- sum: row.sumValue,
- min: row.minValue,
- max: row.maxValue,
- latestAt: row.latestAt,
- latest: row.latestValue,
- });
- }
-
- for (const entries of Object.values(byType)) {
- entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
- }
- const stats: DenseMeasurementSummary["stats"] = {};
- for (const [key, value] of statsAcc) {
- stats[key] = {
- count: value.count,
- avg: value.sum / value.count,
- min: value.min,
- max: value.max,
- latest: value.latest,
- };
- }
- const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {};
- for (const [key, value] of glucoseStatsAcc) {
- glucoseStats[key] = {
- count: value.count,
- avg: value.sum / value.count,
- min: value.min,
- max: value.max,
- latest: value.latest,
- };
- }
-
- return {
- byType,
- stats,
- glucoseStats,
- glucoseClinical: glucoseClinicalFromBuckets(canonical, windowDays),
- };
-}
-
-/**
- * Aggregate the doctor-report payload for a user over a `[start, end]` range.
- * Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
- *
- * `range` is validated via `normaliseDateRange()` upstream; this function
- * trusts it and uses both bounds in the Prisma `where` clause so a custom
- * window (not just "last N days") filters correctly.
- */
-export async function collectDoctorReportData(
- userId: string,
- range: DoctorReportRange,
- options: CollectDoctorReportOptions = {},
-): Promise {
- const { start, end, days } = range;
-
- // Resolve section toggles up-front. Mood is privacy-sensitive: when
- // it's off we don't issue the `MoodEntry` findMany at all so the data
- // never lands in this process's memory. Other sections are queried
- // either way (the queries are cheap on indexed columns) and stripped
- // from the returned payload below — keeping a single source of
- // truth for the data shape regardless of toggle state.
- // v1.18.0 — resolve the per-user module map up-front (once per build). A
- // disabled module's section/resources are excluded from the export so the
- // PDF + FHIR bundle reflect only the modules the user keeps. The map is
- // ANDed over the user's per-report `sections` toggles: a disabled module
- // forces its section off even if the report toggle says on (you can't share
- // data for a module you turned off), while leaving the others untouched.
- // Core clinical sections (weight / BP / pulse / medications) have no module
- // key and stay unconditional. Injectable for tests.
- const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId));
-
- // v1.30.22 — fail-closed backstop on the aggregate's OWN module key,
- // mirroring `loadFhirContext`.
- //
- // Everything below gates the per-domain SECTIONS (mood / sleep / glucose /
- // cycle / labs / recovery / workouts) but nothing ever gated `doctorReport`
- // itself — the key that decides whether the whole-record aggregate may be
- // assembled at all. That left it entirely to callers to remember, and the
- // MCP surface did not: `healthlog://report/doctor-visit`, its `{window}`
- // template, and the `doctor_visit_summary` prompt all assembled the full
- // record for an account with the module off, on the one wire that egresses
- // to a third-party assistant.
- //
- // REFUSE, not omit — the opposite of the per-domain reads. This is the
- // whole-record aggregate, so there is no honest partial answer to return;
- // `/api/export/health-record` already answers 403 for exactly this state
- // and this is the same payload. It throws rather than returning an envelope
- // because reaching here means a caller is missing its gate: a bug to
- // surface, not a flow to serve. Callers that can degrade gracefully gate
- // themselves BEFORE calling (see the MCP resources and the clinician share).
- if (moduleMap.doctorReport === false) {
- throw new Error(
- 'collectDoctorReportData called with the "doctorReport" module ' +
- "disabled — the calling surface is missing its module gate",
- );
- }
-
- const sections: DoctorReportPrefs = {
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...(options.sections ?? {}),
- };
- // Module gate: a disabled module wins over the report toggle. Each report
- // section maps to its owning module key; core sections (bp/weight/pulse/
- // bmi/compliance) carry no key and are never gated here.
- if (moduleMap.mood === false) sections.mood = false;
- if (moduleMap.sleep === false) sections.sleep = false;
- if (moduleMap.glucose === false) sections.glucose = false;
- if (moduleMap.cycle === false) sections.cycle = false;
- if (moduleMap.labs === false) sections.labs = false;
- // Module gates with no `sections` key — applied directly to the data slices
- // below (recovery/strain wellness scores, workout series). Glucose now
- // carries its own report section toggle, into which the module gate above
- // already folds — so the section flag is the single lever for the glucose
- // panel (stats + ranges + clinical metrics).
- const glucoseEnabled = sections.glucose !== false;
- const recoveryEnabled = moduleMap.recovery !== false;
- const workoutsEnabled = moduleMap.workouts !== false;
-
- // 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 },
- select: {
- username: true,
- dateOfBirth: true,
- gender: true,
- heightCm: true,
- glucoseUnit: true,
- thresholdsJson: true,
- timezone: true,
- sourcePriorityJson: true,
- fullName: true,
- insurerName: true,
- insurerIkNumber: true,
- },
- });
- const reportTz = userProfile?.timezone ?? "Europe/Berlin";
- const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
- const densePulse =
- aggregateDenseTypes && !excludedMeasurementTypes.includes("PULSE");
- const denseGlucose =
- aggregateDenseTypes && !excludedMeasurementTypes.includes("BLOOD_GLUCOSE");
- const rawExcludedMeasurementTypes = [...excludedMeasurementTypes];
- if (densePulse) rawExcludedMeasurementTypes.push("PULSE");
- if (denseGlucose) rawExcludedMeasurementTypes.push("BLOOD_GLUCOSE");
-
- const [measurements, medications, intakeEvents, moodEntries, denseBuckets] =
- await Promise.all([
- prisma.measurement.findMany({
- // v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
- // the doctor-report aggregator so deleted rows never leak into
- // the JSON payload or the server-rendered PDF.
- where: {
- userId,
- measuredAt: { gte: start, lte: end },
- deletedAt: null,
- ...(rawExcludedMeasurementTypes.length > 0
- ? { type: { notIn: rawExcludedMeasurementTypes } }
- : {}),
- },
- orderBy: { measuredAt: "asc" },
- // v1.28.25 — narrow select. The collector reads exactly these seven
- // fields (canonical-source collapse: type / value / measuredAt /
- // source / deviceType; sleep-night reconstruction adds sleepStage;
- // the glucose panel adds glucoseContext). The full-width read pulled
- // every column — including the notesEncrypted Bytes — across up to
- // 730 days of rows, which on a CGM + per-sample-HR account is the
- // v1.28.2x six-figure-row incident class.
- select: {
- type: true,
- value: true,
- measuredAt: true,
- source: true,
- deviceType: true,
- sleepStage: true,
- glucoseContext: true,
- },
- }),
- prisma.medication.findMany({
- where: { userId, active: true },
- include: {
- // v1.15.20 — the shared compliance select (plus `label`, which the
- // FHIR schedule mapping below needs) so a future engine column added
- // to SCHEDULE_COMPLIANCE_SELECT reaches the doctor-report surface.
- schedules: { select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true } },
- // v1.17 W1a — archived schedule eras so the ledger compliance
- // builder segments expected-slot expansion against the schedule
- // that was live on each past day (matches the detail page).
- scheduleRevisions: {
- orderBy: { validFrom: "asc" },
- select: {
- id: true,
- validFrom: true,
- validUntil: true,
- payload: true,
- supersededByRevisionId: true,
- },
- },
- // v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
- pauseEras: { select: { pausedAt: true, resumedAt: true } },
- // v1.4.25 W4d — eager-load dose history + recent intake site
- // for any active medication. Generic meds carry empty arrays
- // so the legacy data path is byte-identical.
- doseChanges: { orderBy: { effectiveFrom: "asc" } },
- intakeEvents: {
- where: { takenAt: { not: null } },
- orderBy: { takenAt: "desc" },
- take: 1,
- select: { takenAt: true, injectionSite: true },
- },
- },
- }),
- prisma.medicationIntakeEvent.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the doctor report.
- where: {
- userId,
- deletedAt: null,
- scheduledFor: { gte: start, lte: end },
- },
- include: {
- // v1.9.0 — carry the medication identity + codes + delivery
- // form so the FHIR MedicationAdministration builder can emit a
- // self-describing `medicationCodeableConcept` and a route.
- medication: {
- select: {
- id: true,
- name: true,
- dose: true,
- atcCode: true,
- rxNormCode: true,
- deliveryForm: true,
- asNeeded: true,
- },
- },
- },
- orderBy: { scheduledFor: "asc" },
- }),
- // Mood data: zero DB read when the user opted out. This is the
- // privacy-by-default contract — the JSON payload + audit log
- // both reflect "mood was never fetched", not "mood was fetched
- // and then dropped".
- sections.mood
- ? prisma.moodEntry.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the doctor report.
- where: {
- userId,
- deletedAt: null,
- moodLoggedAt: { gte: start, lte: end },
- },
- orderBy: { moodLoggedAt: "asc" },
- // v1.28.46 perf (H1) — narrow select. The collector reads exactly
- // two fields off each mood row: `score` (the mood summary /
- // distribution) and `tags` (GLP-1 side-effect tag counts). The
- // full-width read pulled every column — including the encrypted
- // `noteEncrypted` Bytes and the plaintext `note` free text — across
- // the whole report window, none of which the report ever touches.
- select: { score: true, tags: true },
- })
- : Promise.resolve([]),
- densePulse || denseGlucose
- ? prisma.$queryRaw`
- WITH localized AS (
- SELECT
- m."id",
- m."type",
- m."source",
- m."device_type",
- m."glucose_context",
- m."value",
- m."measured_at",
- date_trunc(
- 'day',
- (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
- ) AS local_day
- FROM measurements m
- WHERE m."user_id" = ${userId}
- AND m."measured_at" >= ${start}
- AND m."measured_at" <= ${end}
- AND m."deleted_at" IS NULL
- AND (
- (${densePulse} AND m."type" = 'PULSE'::"measurement_type")
- OR
- (${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
- )
- ),
- scored AS (
- SELECT
- localized.*,
- CASE
- WHEN "value" > 0
- THEN 1.509 * (power(ln("value"), 1.084) - 5.381)
- ELSE NULL
- END AS risk_transform
- FROM localized
- ),
- latest_rows AS (
- SELECT DISTINCT ON (
- local_day,
- "type",
- "source",
- "device_type",
- "glucose_context"
- )
- local_day,
- "type",
- "source",
- "device_type",
- "glucose_context",
- "measured_at" AS latest_at,
- "value" AS latest_value
- FROM localized
- ORDER BY
- local_day,
- "type",
- "source",
- "device_type",
- "glucose_context",
- "measured_at" DESC,
- "id" DESC
- ),
- grouped AS (
- SELECT
- local_day,
- "type",
- "source",
- "device_type",
- "glucose_context",
- COUNT(*)::int AS sample_count,
- SUM("value")::double precision AS sum_value,
- MIN("value")::double precision AS min_value,
- MAX("value")::double precision AS max_value,
- COUNT(*) FILTER (WHERE "value" > 0)::int AS clinical_count,
- SUM("value") FILTER (WHERE "value" > 0)::double precision
- AS clinical_sum,
- SUM("value" * "value") FILTER (WHERE "value" > 0)::double precision
- AS clinical_sum_squares,
- MIN("measured_at") FILTER (WHERE "value" > 0) AS clinical_first_at,
- MAX("measured_at") FILTER (WHERE "value" > 0) AS clinical_last_at,
- COUNT(*) FILTER (WHERE "value" BETWEEN 70 AND 180)::int
- AS clinical_tir_count,
- COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 70)::int
- AS clinical_tbr1_count,
- COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 54)::int
- AS clinical_tbr2_count,
- COUNT(*) FILTER (WHERE "value" > 180)::int
- AS clinical_tar1_count,
- COUNT(*) FILTER (WHERE "value" > 250)::int
- AS clinical_tar2_count,
- SUM(
- CASE
- WHEN risk_transform < 0
- THEN 10 * risk_transform * risk_transform
- ELSE 0
- END
- ) FILTER (WHERE risk_transform IS NOT NULL)::double precision
- AS clinical_low_risk_sum,
- SUM(
- CASE
- WHEN risk_transform > 0
- THEN 10 * risk_transform * risk_transform
- ELSE 0
- END
- ) FILTER (WHERE risk_transform IS NOT NULL)::double precision
- AS clinical_high_risk_sum
- FROM scored
- GROUP BY
- local_day,
- "type",
- "source",
- "device_type",
- "glucose_context"
- )
- SELECT
- grouped.local_day AT TIME ZONE ${reportTz} AS "bucketStart",
- grouped."type",
- grouped."source",
- grouped."device_type" AS "deviceType",
- grouped."glucose_context" AS "glucoseContext",
- grouped.sample_count AS "count",
- grouped.sum_value AS "sumValue",
- grouped.min_value AS "minValue",
- grouped.max_value AS "maxValue",
- latest_rows.latest_at AS "latestAt",
- latest_rows.latest_value::double precision AS "latestValue",
- grouped.clinical_count AS "clinicalCount",
- grouped.clinical_sum AS "clinicalSum",
- grouped.clinical_sum_squares AS "clinicalSumSquares",
- grouped.clinical_first_at AS "clinicalFirstAt",
- grouped.clinical_last_at AS "clinicalLastAt",
- grouped.clinical_tir_count AS "clinicalTirCount",
- grouped.clinical_tbr1_count AS "clinicalTbr1Count",
- grouped.clinical_tbr2_count AS "clinicalTbr2Count",
- grouped.clinical_tar1_count AS "clinicalTar1Count",
- grouped.clinical_tar2_count AS "clinicalTar2Count",
- grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
- grouped.clinical_high_risk_sum AS "clinicalHighRiskSum"
- FROM grouped
- INNER JOIN latest_rows
- ON latest_rows.local_day = grouped.local_day
- AND latest_rows."type" = grouped."type"
- AND latest_rows."source" = grouped."source"
- AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
- AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
- ORDER BY "bucketStart" ASC, grouped."type" ASC
- `
- : Promise.resolve([]),
- ]);
-
- const denseSummary = summariseDenseBuckets(
- denseBuckets,
- reportTz,
- userProfile?.sourcePriorityJson ?? null,
- days,
- );
-
- // v1.18.0 — collapse each multi-source metric to its CANONICAL source before
- // grouping, so the report's per-type avg/min/max match the dashboard rather
- // than blending overlapping sources (e.g. WHOOP + Apple Watch resting-heart-
- // rate summed into one inflated mean). See `collapseMeasurementsToCanonical`.
- const canonicalMeasurements = collapseMeasurementsToCanonical(
- measurements,
- reportTz,
- userProfile?.sourcePriorityJson ?? null,
- );
-
- // Group measurements by type (canonical-source-collapsed).
- const byType: Record<
- string,
- Array<{ value: number; measuredAt: string }>
- > = {};
- for (const m of canonicalMeasurements) {
- if (!byType[m.type]) byType[m.type] = [];
- byType[m.type].push({
- value: m.value,
- measuredAt: m.measuredAt.toISOString(),
- });
- }
- for (const [type, entries] of Object.entries(denseSummary.byType)) {
- byType[type] = entries;
- }
- // measuredAt-ascending within each type so `latest` (last element) is the
- // most recent reading after the canonical collapse reorders by bucket.
- for (const entries of Object.values(byType)) {
- entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
- }
-
- // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a
- // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface
- // (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed)
- // shows the per-night reconstructed asleep total via `summarizeSleepNights`,
- // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route
- // the report's sleep value through the same engine — one number, one surface
- // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`.
- const sleepRows = measurements.filter(
- (m) => m.type === "SLEEP_DURATION",
- ) as unknown as SleepStageRow[];
- if (sleepRows.length > 0) {
- const nights = reconstructSleepNights(
- sleepRows,
- reportTz,
- userProfile?.sourcePriorityJson ?? null,
- ).filter((n) => n.asleepMinutes > 0);
- // Per-night asleep totals, ascending by night, replace the raw per-stage
- // rows so the clinical vitals table reads time-asleep hours, not stage-row
- // minutes. Empty (no scorable night) drops SLEEP_DURATION entirely.
- if (nights.length > 0) {
- byType.SLEEP_DURATION = nights.map((n) => ({
- value: n.asleepMinutes,
- measuredAt: n.measuredAt.toISOString(),
- }));
- } else {
- delete byType.SLEEP_DURATION;
- }
- }
-
- // Per-type stats.
- const stats: Record = {};
- for (const [type, entries] of Object.entries(byType)) {
- const values = entries.map((e) => e.value);
- stats[type] = {
- avg: values.reduce((a, b) => a + b, 0) / values.length,
- ...minMaxOf(values),
- count: values.length,
- latest: values[values.length - 1],
- };
- }
- Object.assign(stats, denseSummary.stats);
-
- // v1.17 W1a — medication compliance through the dose-ledger authority (the
- // same engine the detail page uses), NOT a raw-row tally. Routes each
- // scheduled medication's intake rows through the cadence-aware band minter
- // over the report window so the PDF / FHIR adherence % matches the app's
- // detail page (slot dedup, band timing, cadence honoured). As-needed / PRN
- // medications are excluded — no schedule, no expected dose, no fabricated
- // 100 % on a clinical report. The medication itself stays on the list.
- const compliance = buildLedgerCompliance(
- medications.map((m) => ({
- id: m.id,
- name: m.name,
- asNeeded: m.asNeeded,
- startsOn: m.startsOn,
- endsOn: m.endsOn,
- oneShot: m.oneShot,
- createdAt: m.createdAt,
- schedules: m.schedules,
- scheduleRevisions: m.scheduleRevisions,
- pauseEras: m.pauseEras,
- })),
- intakeEvents.map((e) => ({
- medicationId: e.medicationId,
- scheduledFor: e.scheduledFor,
- takenAt: e.takenAt,
- skipped: e.skipped,
- autoMissed: e.autoMissed ?? false,
- attributionSource: e.attributionSource ?? undefined,
- })),
- reportTz,
- start,
- end,
- end,
- );
-
- // v1.9.0 — MedicationAdministration source rows. One entry per acted
- // intake (taken OR explicitly skipped); pending / missed rows are
- // dropped so the FHIR export never asserts an administration that did
- // not happen, and tombstoned rows are already excluded by the query
- // `deletedAt: null` predicate. The structured `dose` is resolved from
- // the medication's `MedicationDoseChange` history — the latest change
- // effective at or before the administration instant — when one exists.
- //
- // Dose-change history is loaded on the `medications` array (active
- // meds only); a per-medicationId index lets the resolver run in O(1)
- // lookups + a short linear scan over the (typically small) change list.
- const doseChangesByMedId = new Map<
- string,
- Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
- >();
- for (const m of medications) {
- doseChangesByMedId.set(
- m.id,
- m.doseChanges.map((dc) => ({
- effectiveFrom: dc.effectiveFrom,
- doseValue: dc.doseValue,
- doseUnit: dc.doseUnit,
- })),
- );
- }
- const resolveDoseInEffect = (
- medicationId: string,
- at: Date,
- ): { value: number; unit: string } | null => {
- const changes = doseChangesByMedId.get(medicationId);
- if (!changes || changes.length === 0) return null;
- // `doseChanges` are loaded ordered by `effectiveFrom asc`; take the
- // last one whose effectiveFrom is <= the administration instant.
- let inEffect: { value: number; unit: string } | null = null;
- for (const c of changes) {
- if (c.effectiveFrom.getTime() <= at.getTime()) {
- inEffect = { value: c.doseValue, unit: c.doseUnit };
- } else {
- break;
- }
- }
- return inEffect;
- };
-
- const medicationAdministrations: NonNullable<
- DoctorReportData["medicationAdministrations"]
- > = [];
- for (const event of intakeEvents) {
- // Only acted rows: a taken dose (completed) or an explicit skip
- // (not-done). A scheduled-but-unconfirmed ("missed") slot is not an
- // administration event and is omitted entirely.
- const isTaken = event.takenAt !== null;
- if (!isTaken && !event.skipped) continue;
- const effectiveAt = isTaken ? (event.takenAt as Date) : event.scheduledFor;
- medicationAdministrations.push({
- medicationName: event.medication.name,
- effectiveAt: effectiveAt.toISOString(),
- status: isTaken ? "completed" : "not-done",
- doseText: event.medication.dose || null,
- // Structured dose only meaningful for a taken dose with a
- // dose-change history; a skip records no dose consumed.
- dose: isTaken
- ? resolveDoseInEffect(event.medication.id, effectiveAt)
- : null,
- injectionSite: event.injectionSite ?? null,
- atcCode: event.medication.atcCode ?? null,
- rxNormCode: event.medication.rxNormCode ?? null,
- deliveryForm: event.medication.deliveryForm ?? null,
- });
- }
-
- // v1.9.0 — bound the administration set. `intakeEvents` is ordered
- // `scheduledFor: asc`, so the most-recent acted rows are at the tail;
- // keep the last N and flag the trim so the FHIR narrative can disclose
- // it. The omitted rows are the OLDEST in the window — recent adherence
- // is preserved intact. The cap is a coarse safety ceiling (the report
- // window is the natural bound; this only guards a pathological
- // multi-year, many-medication export) — resolved per call from
- // `FHIR_MAX_MEDICATION_ADMINISTRATIONS` so an operator override takes
- // effect without a code change.
- const maxMedicationAdministrations = resolveMaxMedicationAdministrations(
- process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
- );
- const totalAdministrations = medicationAdministrations.length;
- const medicationAdministrationsTruncated =
- totalAdministrations > maxMedicationAdministrations;
- const cappedAdministrations = medicationAdministrationsTruncated
- ? medicationAdministrations.slice(
- totalAdministrations - maxMedicationAdministrations,
- )
- : medicationAdministrations;
-
- // Mood summary.
- const moodScores = moodEntries.map((e) => e.score);
- const mood: DoctorReportMood | null =
- moodScores.length > 0
- ? {
- avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
- min: minMaxOf(moodScores).min,
- max: minMaxOf(moodScores).max,
- count: moodScores.length,
- distribution: {
- 1: moodScores.filter((s) => s === 1).length,
- 2: moodScores.filter((s) => s === 2).length,
- 3: moodScores.filter((s) => s === 3).length,
- 4: moodScores.filter((s) => s === 4).length,
- 5: moodScores.filter((s) => s === 5).length,
- },
- }
- : null;
-
- // BMI from latest weight + profile height.
- const weightStats = stats.WEIGHT;
- const bmiRaw =
- weightStats && userProfile?.heightCm
- ? weightStats.latest / (userProfile.heightCm / 100) ** 2
- : null;
- const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
-
- // Per-context glucose stats + effective ranges (canonical mg/dL).
- const glucoseStats: Record = denseGlucose
- ? { ...denseSummary.glucoseStats }
- : {};
- const glucoseRanges: Record = {};
- // v1.18.0 — when the glucose module is disabled, no glucose row enters the
- // panel: stats, ranges, and the clinical metrics all collapse to empty so the
- // PDF + FHIR Observations carry no glucose for this user.
- const glucoseRows = glucoseEnabled
- ? measurements.filter((m) => m.type === "BLOOD_GLUCOSE")
- : [];
- const overrides = (userProfile?.thresholdsJson ??
- null) as ThresholdOverridesJson | null;
- const profileForRange = {
- heightCm: userProfile?.heightCm ?? null,
- dateOfBirth: userProfile?.dateOfBirth ?? null,
- gender: userProfile?.gender ?? null,
- };
- for (const ctx of GLUCOSE_CONTEXTS) {
- if (!denseGlucose) {
- const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
- if (rows.length === 0) continue;
- const values = rows.map((r) => r.value);
- glucoseStats[ctx] = {
- avg: values.reduce((a, b) => a + b, 0) / values.length,
- ...minMaxOf(values),
- count: values.length,
- latest: values[values.length - 1],
- };
- } else if (!glucoseStats[ctx]) {
- continue;
- }
- const eff = getEffectiveRange(
- thresholdMetricForContext(ctx),
- profileForRange,
- overrides,
- );
- if (eff.range) {
- glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
- }
- }
-
- // v1.17.0 — clinical panel over the WHOLE report period (all contexts
- // pooled), computed by the one literature-locked engine the insights panel
- // and the coach also consume. `windowDays` is the report's own period so the
- // TIR / GMI / eA1C / CV% reflect exactly the readings the rest of the report
- // tabulates; `now: end` anchors the window to the report's upper bound. The
- // learning gate keeps a thin period from asserting a clinical AGP off spot
- // data. Values stay canonical mg/dL; the renderer converts with `glucoseUnit`.
- const glucoseClinical = denseGlucose
- ? denseSummary.glucoseClinical
- : computeGlucoseClinicalMetrics(
- glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
- { windowDays: days, now: end },
- );
-
- // `practiceName` is sanitised to a single-line string with a hard length
- // cap. The PDF cover prints it verbatim — never let unbounded input land in
- // a layout-sensitive header.
- const practiceName = sanitisePracticeName(options.practiceName);
-
- // Apply section toggles. Removing the type's keys from `byType`,
- // `stats`, and `compliance` lets the PDF renderer treat "section
- // disabled" identically to "section had no rows" — both paths skip
- // the table. Mood is already null when disabled (we never queried).
- const filteredByType = filterMeasurementKeys(byType, sections, moduleMap);
- const filteredStats = filterMeasurementKeys(stats, sections, moduleMap);
- const filteredCompliance = sections.compliance ? compliance : {};
- const filteredBmi = sections.bmi ? bmi : null;
-
- // v1.4.25 W4d — GLP-1 therapy section. Only assembled when the user
- // has at least one active GLP-1 medication AND the compliance toggle
- // is on (per the privacy contract: a doctor-report dose history needs
- // the compliance section enabled to be useful). Generic accounts
- // get `glp1: null` which the PDF renderer skips entirely.
- const glp1Meds = medications.filter((m) => m.treatmentClass === "GLP1");
- let glp1: DoctorReportData["glp1"] = null;
- if (sections.compliance && glp1Meds.length > 0) {
- const weightSorted = (byType.WEIGHT ?? []).slice();
- const weightStartKg = weightSorted[0]?.value ?? null;
- const weightEndKg = weightSorted[weightSorted.length - 1]?.value ?? null;
- const weightDeltaKg =
- weightStartKg !== null && weightEndKg !== null
- ? Math.round((weightEndKg - weightStartKg) * 10) / 10
- : null;
-
- // Side-effect tag counts (only when mood section is enabled — we
- // already gated the moodEntries read on `sections.mood`, so this
- // collapses to "no tags" when the user opted out).
- const sideEffectTags = new Set([
- "nausea",
- "constipation",
- "diarrhea",
- "fatigue",
- "appetite-loss",
- "heartburn",
- "headache",
- "übelkeit",
- "verstopfung",
- "durchfall",
- "müdigkeit",
- "appetitlosigkeit",
- "sodbrennen",
- "kopfschmerzen",
- ]);
- const sideEffectCounts = new Map();
- for (const mood of moodEntries) {
- const rawTags = mood.tags ?? "";
- let tags: string[];
- try {
- const parsed = JSON.parse(rawTags);
- tags = Array.isArray(parsed)
- ? parsed
- .map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
- .filter(Boolean)
- : [];
- } catch {
- tags = rawTags
- .split(",")
- .map((p) => p.trim())
- .filter(Boolean);
- }
- for (const tag of tags) {
- const lower = tag.toLowerCase();
- if (!sideEffectTags.has(lower)) continue;
- sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
- }
- }
-
- glp1 = {
- medications: glp1Meds.map((m) => {
- const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
- const lastIntake = m.intakeEvents[0] ?? null;
- const comp = compliance[m.name] ?? {
- taken: 0,
- total: 0,
- skipped: 0,
- missed: 0,
- };
- return {
- name: m.name,
- currentDose: latest
- ? {
- value: latest.doseValue,
- unit: latest.doseUnit,
- since: latest.effectiveFrom.toISOString(),
- }
- : null,
- doseHistory: m.doseChanges.map((dc) => ({
- value: dc.doseValue,
- unit: dc.doseUnit,
- effectiveFrom: dc.effectiveFrom.toISOString(),
- note: readNote(dc.noteEncrypted, dc.note),
- })),
- lastInjection:
- lastIntake && lastIntake.takenAt
- ? {
- date: lastIntake.takenAt.toISOString(),
- site: lastIntake.injectionSite,
- }
- : null,
- compliance: { taken: comp.taken, total: comp.total },
- };
- }),
- weightStartKg,
- weightEndKg,
- weightDeltaKg,
- sideEffects: Array.from(sideEffectCounts.entries())
- .sort((a, b) => b[1] - a[1])
- .map(([tag, count]) => ({ tag, count })),
- };
- }
-
- // v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
- // in `byType`/`stats` (they're only filtered out of the clinical vitals
- // table). Summarise each present score type for the separate "Wellness
- // summary" section. Empty array → null so the renderer + FHIR builder skip
- // the section entirely.
- //
- // RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
- // the same day; mixing them into one min/avg/max would blend two distinct
- // series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
- // the SAME value the tile + iOS feed show — one number, one engine.
- const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
- // v1.18.0 — module gate per score type. Recovery + stress are the
- // recovery/readiness signals (recovery module); strain is the
- // training-load signal derived from the workouts table (workouts module).
- // A disabled owning module drops the score from the wellness summary and,
- // since the FHIR exporter sources these Observations from
- // `data.wellnessScores`, from the bundle too.
- if (type === "STRAIN_SCORE" && !workoutsEnabled) return [];
- if (
- (type === "RECOVERY_SCORE" || type === "STRESS_SCORE") &&
- !recoveryEnabled
- ) {
- return [];
- }
- if (type === "RECOVERY_SCORE") {
- const summary = summariseCanonicalRecovery(measurements, reportTz);
- return summary ? [summary] : [];
- }
- const s = stats[type];
- const rows = byType[type];
- if (!s || !rows || rows.length === 0) return [];
- return [
- {
- type,
- latest: Math.round(s.latest),
- avg: Math.round(s.avg),
- min: Math.round(s.min),
- max: Math.round(s.max),
- count: s.count,
- latestAt: rows[rows.length - 1].measuredAt,
- },
- ];
- });
- const wellnessScores =
- wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
-
- // v1.15.0 — cycle summary. Privacy default OFF: only read + assemble when
- // the `cycle` section toggle is explicitly ON. Statistics only — the
- // helper never touches `notesEncrypted`, so no plaintext free-text can
- // leak through this surface. `null` when disabled or no observed cycle.
- let cycle: DoctorReportData["cycle"] = null;
- if (sections.cycle) {
- cycle = await buildCycleExportSummary(
- userId,
- end.toISOString().slice(0, 10),
- );
- }
-
- // v1.17.1 — structured lab results over the window. ON by default; the
- // user recorded these specifically to share with a clinician, so the
- // privacy stance matches BP / weight, not mood / cycle. We reduce to the
- // latest reading per analyte (with a count) so the report is a concise
- // panel, not a raw dump; notes are never read here.
- let labResults: DoctorReportData["labResults"] = null;
- if (sections.labs) {
- const labRows = await prisma.labResult.findMany({
- where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
- orderBy: { takenAt: "asc" },
- select: {
- panel: true,
- analyte: true,
- value: true,
- valueText: true,
- unit: true,
- referenceLow: true,
- referenceHigh: true,
- takenAt: true,
- },
- });
- // Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold
- // together; rows are ascending so the last seen wins as the latest.
- const byAnalyte = new Map<
- string,
- NonNullable[number]
- >();
- for (const r of labRows) {
- const key = r.analyte.toLowerCase();
- const prev = byAnalyte.get(key);
- byAnalyte.set(key, {
- panel: r.panel,
- analyte: r.analyte,
- value: r.value,
- valueText: r.valueText,
- unit: r.unit,
- referenceLow: r.referenceLow,
- referenceHigh: r.referenceHigh,
- takenAt: r.takenAt.toISOString(),
- count: (prev?.count ?? 0) + 1,
- });
- }
- const collapsed = Array.from(byAnalyte.values());
- labResults = collapsed.length > 0 ? collapsed : null;
- }
-
- // v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
- // on the illness module (default-on, opt-out): when off, no read, no section.
- // An episode overlaps when it began on or before the window end AND is
- // either still ongoing or resolved on or after the window start. Labels +
- // lifecycle + dates only — the encrypted note is never selected, so no
- // free-text leaks into the clinical export.
- let illnessEpisodes: DoctorReportData["illnessEpisodes"] = null;
- if (moduleMap.illness !== false) {
- const episodeRows = await prisma.illnessEpisode.findMany({
- where: {
- userId,
- deletedAt: null,
- onsetAt: { lte: end },
- OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }],
- },
- orderBy: { onsetAt: "asc" },
- select: {
- label: true,
- type: true,
- lifecycle: true,
- onsetAt: true,
- resolvedAt: true,
- },
- });
- const mapped = episodeRows.map((e) => ({
- label: e.label,
- type: e.type,
- lifecycle: e.lifecycle,
- onsetAt: e.onsetAt.toISOString(),
- resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
- }));
- illnessEpisodes = mapped.length > 0 ? mapped : null;
- }
-
- // v1.27.x — structured allergies + family history. Reference data, not
- // time-windowed (a penicillin allergy does not expire with the report
- // window), so no date filter. Riding the section toggles keeps the "you
- // can deselect any section" contract; both default ON — a clinical
- // report without an allergy section is the riskier default. The
- // reaction description decrypts fail-soft per row; the free-text notes
- // are never selected, matching the illness-journal stance.
- let allergies: DoctorReportData["allergies"] = null;
- if (sections.allergies) {
- const rows = await prisma.allergy.findMany({
- where: { userId, deletedAt: null },
- orderBy: { createdAt: "asc" },
- select: {
- substance: true,
- category: true,
- type: true,
- severity: true,
- status: true,
- reactionEncrypted: true,
- },
- });
- const mapped = rows.map((r) => {
- const { reaction, reactionUnreadable } = decryptAllergyReaction(
- r.reactionEncrypted,
- );
- if (reactionUnreadable) {
- // A reaction WAS recorded but is undecryptable (key gap / GCM
- // corruption). It is flagged (not silently blanked) so the PDF renders
- // an honest "unreadable" marker; log the swallowed decrypt so a
- // systemic key-config failure is visible rather than masquerading as
- // "no reaction recorded" on a clinician-facing export.
- getEvent()?.addWarning(
- `doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`,
- );
- }
- return {
- substance: r.substance,
- category: r.category,
- type: r.type,
- severity: r.severity,
- status: r.status,
- reaction,
- reactionUnreadable,
- };
- });
- allergies = mapped.length > 0 ? mapped : null;
- }
-
- let familyHistory: DoctorReportData["familyHistory"] = null;
- if (sections.familyHistory) {
- const rows = await prisma.familyHistoryEntry.findMany({
- where: { userId, deletedAt: null },
- orderBy: { createdAt: "asc" },
- select: { relationship: true, condition: true, ageAtOnset: true },
- });
- familyHistory = rows.length > 0 ? rows : null;
- }
-
- return {
- period: {
- days,
- // `since` is preserved for backwards compatibility with any in-flight
- // clients that read the old field name; mirrors `start`.
- since: start.toISOString(),
- start: start.toISOString(),
- end: end.toISOString(),
- },
- patient: {
- username: userProfile?.username ?? null,
- dateOfBirth: userProfile?.dateOfBirth
- ? userProfile.dateOfBirth.toISOString()
- : null,
- gender: userProfile?.gender ?? null,
- heightCm: userProfile?.heightCm ?? null,
- fullName: userProfile?.fullName ?? null,
- insurerName: userProfile?.insurerName ?? null,
- insurerIkNumber: userProfile?.insurerIkNumber ?? null,
- },
- practiceName,
- measurements: filteredByType,
- stats: filteredStats,
- glucoseStats,
- glucoseRanges,
- // Surfaced by the insights glucose views; the doctor-PDF / FHIR rendering of
- // these clinical metrics is staged for a later release, so no report
- // consumer reads this field yet — it is forward-populated, not dead.
- glucoseClinical,
- glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
- bmi: filteredBmi,
- compliance: filteredCompliance,
- // 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,
- cycle,
- labResults,
- illnessEpisodes,
- allergies,
- familyHistory,
- };
-}
+export { collectDoctorReportData } from "./doctor-report/collect";
diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts
index 11d58427c..0dbf833f9 100644
--- a/src/lib/doctor-report-pdf-core.ts
+++ b/src/lib/doctor-report-pdf-core.ts
@@ -10,7 +10,13 @@
* `%PDF-` byte stream in both environments.
*/
import { jsPDF } from "jspdf";
-import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys";
+import type { MeasurementType } from "@/generated/prisma/client";
+import {
+ isStructuredLeafId,
+ REPORT_GROUPS,
+} from "./report-selection/catalogue";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurements/type-label-keys";
+import { getUnitForType } from "./validations/measurement";
import {
makeFormatters,
DISPLAY_TIMEZONE,
@@ -154,20 +160,13 @@ export interface DoctorReportRenderOptions {
* Defaults to `true`. Off produces a compact text-only report.
*/
includeCharts?: boolean;
- /**
- * v1.7.0 — optional AI summary text. OUT of the clinical PDF by
- * default; rendered ONLY when the user explicitly opts in, under a
- * clearly-labelled "AI summary — not clinically validated" heading.
- * Null/undefined/empty omits the section entirely.
- */
- aiSummary?: string | null;
}
-// Per-vital label keys live in a jsPDF-free module so leaf consumers (the
-// public clinician share view) can import the map without dragging the PDF
-// renderer into their graph. Re-exported here so the PDF core stays the
-// single import surface its existing callers already use.
-export { DOCTOR_REPORT_TYPE_LABEL_KEYS };
+// Per-type label keys live in a lucide-free module so leaf consumers — the
+// public clinician share view, this renderer — can import the map without
+// dragging icon components into their graph. Re-exported here so the PDF core
+// stays the single import surface its existing callers already use.
+export { MEASUREMENT_TYPE_LABEL_KEYS };
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = {
WEIGHT: "kg",
@@ -183,50 +182,26 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = {
};
/**
- * Vital types rendered in the main vitals table. Body composition
- * (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings
- * smart scales report all three together. SpO2 (Withings ScanWatch type
- * 54, HealthKit, n8n / Health Connect) is rendered last in the same
- * table for clinical readability. Glucose ships separately via
- * per-context `glucoseStats`. SLEEP_DURATION rides this table too: the
- * `sleep` section toggle is default-ON and the data layer reconstructs a
- * per-night time-asleep total (in minutes) for exactly this row — the
- * render loop converts it to hours so value and unit (`h`) agree, matching
- * the FHIR sleep Observation. Activity (steps) stays excluded from a
- * clinical-focused report.
+ * The measurement table is driven by the selection, not by a whitelist.
*
- * v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
- * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
- * they are 0–100 composites recomputed nightly from the underlying signals
- * and carry a "descriptive, not a clinical assessment" disclaimer. They stay
- * out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
- * `measurement-type-enum-coverage.test.ts`).
+ * It used to be a fixed nine types, which meant a user could select resting
+ * heart rate, see it in the FHIR bundle and in the share view, and not find it
+ * in the PDF — a per-format divergence on a document whose whole point is that
+ * every format describes the same record. The table now prints every selected
+ * measurement leaf that has data, sub-headed by the same twelve groups the
+ * selection panel shows, so the panel reads as a table of contents for the
+ * artefact.
*
- * v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived
- * composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an
- * absolute reading) are excluded for the same reason as the other derived /
- * lifestyle signals — they are descriptive, not measured clinical vitals.
- *
- * v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
- * band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
- * not a measured clinical vital — so it is excluded too.
- *
- * v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
- * the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
- * excluded from the vitals table (they ride the module-gated mental-health
- * export section instead, see `doctor-report-data.ts`).
+ * The aggregator has already applied the selection, so `data.stats` carries
+ * exactly the admitted leaves and nothing here can over-serve.
*/
-export const DOCTOR_REPORT_VITAL_TYPES = [
- "WEIGHT",
- "BLOOD_PRESSURE_SYS",
- "BLOOD_PRESSURE_DIA",
- "PULSE",
- "BODY_FAT",
- "TOTAL_BODY_WATER",
- "BONE_MASS",
- "OXYGEN_SATURATION",
- "SLEEP_DURATION",
-] as const;
+export const DOCTOR_REPORT_VITAL_GROUPS = REPORT_GROUPS.map((group) => ({
+ id: group.id,
+ labelKey: group.labelKey,
+ types: group.leaves.filter(
+ (leaf): leaf is MeasurementType => !isStructuredLeafId(leaf),
+ ),
+})).filter((group) => group.types.length > 0);
/**
* Render the doctor report into a `jsPDF` instance.
@@ -247,7 +222,6 @@ export function buildDoctorReportPdfDocument(
timeFormat = "AUTO",
insuranceNumber = null,
includeCharts = true,
- aiSummary = null,
} = options;
const formatters = makeFormatters(locale, userTz, timeFormat);
const num = (value: number, decimals = 1) =>
@@ -261,7 +235,12 @@ export function buildDoctorReportPdfDocument(
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
- return staticUnit ?? "";
+ if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
+ // Every other type falls back to the canonical unit the measurement
+ // validation layer already records. A type with no recorded unit prints
+ // none — an absent unit is absent, not a guessed symbol.
+ const canonical = getUnitForType(type);
+ return canonical === "unknown" ? "" : canonical;
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
@@ -298,14 +277,13 @@ export function buildDoctorReportPdfDocument(
now,
insuranceNumber,
includeCharts,
- aiSummary,
footerTz,
margin,
pageWidth,
pageHeight,
contentMaxY,
tableBottomMargin,
- vitalTypes: DOCTOR_REPORT_VITAL_TYPES,
+ vitalGroups: DOCTOR_REPORT_VITAL_GROUPS,
unitFor,
ensureSpace,
};
diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
index 03d905f6e..efdc85da8 100644
--- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
+++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
@@ -14,19 +14,8 @@ export function buildClinicalRecordsNotesSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
): DoctorReportPdfCursorState {
- const {
- doc,
- data,
- t,
- num,
- fmtDate,
- aiSummary,
- margin,
- pageWidth,
- contentMaxY,
- tableBottomMargin,
- ensureSpace,
- } = context;
+ const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } =
+ context;
let y = state.y;
// v1.17.1 — structured lab-results section. Populated when the `labs`
@@ -298,49 +287,5 @@ export function buildClinicalRecordsNotesSection(
.finalY + 8;
}
- // v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
- // rendered ONLY when the user explicitly opted in. Clearly labelled and
- // flagged as not clinically validated so a physician never mistakes it
- // for a machine-generated diagnosis.
- const aiText = typeof aiSummary === "string" ? aiSummary.trim() : "";
- if (aiText.length > 0) {
- // Keep the heading + the first disclaimer line together.
- y = ensureSpace(y, 5 + 4 + 4.5);
- doc.setFontSize(12);
- doc.setFont("helvetica", "bold");
- doc.setTextColor(120, 80, 0);
- doc.text(t("doctorReport.aiSummaryTitle"), margin, y);
- y += 5;
- doc.setFontSize(8);
- doc.setFont("helvetica", "italic");
- doc.setTextColor(150, 110, 40);
- const disclaimer = doc.splitTextToSize(
- t("doctorReport.aiSummaryDisclaimer"),
- pageWidth - 2 * margin,
- );
- for (const line of disclaimer) {
- if (y + 4 > contentMaxY) {
- doc.addPage();
- y = margin;
- }
- doc.text(line, margin, y);
- y += 4;
- }
- y += 2;
- doc.setFontSize(9);
- doc.setFont("helvetica", "normal");
- doc.setTextColor(60, 60, 60);
- const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin);
- for (const line of wrapped) {
- if (y + 4.5 > contentMaxY) {
- doc.addPage();
- y = margin;
- }
- doc.text(line, margin, y);
- y += 4.5;
- }
- y += 4;
- }
-
return pdfCursorState(doc, y);
}
diff --git a/src/lib/doctor-report-pdf/clinical-summary.ts b/src/lib/doctor-report-pdf/clinical-summary.ts
index d4fef4032..9543f5e45 100644
--- a/src/lib/doctor-report-pdf/clinical-summary.ts
+++ b/src/lib/doctor-report-pdf/clinical-summary.ts
@@ -2,7 +2,8 @@ import {
adherenceRatePercent,
type DoctorReportData,
} from "../doctor-report-data";
-import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "../doctor-report/type-label-keys";
+import type { MeasurementType } from "@/generated/prisma/client";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import type {
DoctorReportNumberFormatter,
DoctorReportTranslator,
@@ -76,7 +77,7 @@ export function buildClinicalSummaryLines(
const arrow = trendArrow(series.map((point) => point.value));
lines.push(
t("doctorReport.summaryTrend", {
- label: t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
+ label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
latest: num(stat.latest, 1),
arrow,
}),
diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts
index c517e82a3..c13cfddca 100644
--- a/src/lib/doctor-report-pdf/measurements-charts-section.ts
+++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts
@@ -1,7 +1,8 @@
import type { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
-import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "../doctor-report/type-label-keys";
+import type { MeasurementType } from "@/generated/prisma/client";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import {
getBmiClassificationKey,
getBpClassificationKey,
@@ -148,6 +149,30 @@ function drawSparkline(
return chartBottom + axisLabelHeight + 2;
}
+/** A body row: either six cells, or a full-width group sub-heading. */
+type VitalTableRow =
+ | string[]
+ | [{ content: string; colSpan: number; styles: Record }];
+
+/**
+ * A full-width sub-heading row naming the group the rows under it belong to.
+ * Bold on a light fill, no colour beyond the table's own greys — the heading
+ * carries meaning, not decoration.
+ */
+function groupSubHeadRow(label: string): VitalTableRow {
+ return [
+ {
+ content: label,
+ colSpan: 6,
+ styles: {
+ fontStyle: "bold",
+ fillColor: [242, 242, 242],
+ textColor: [30, 30, 30],
+ },
+ },
+ ];
+}
+
export function buildMeasurementsChartsSection(
context: DoctorReportPdfRenderContext,
state: DoctorReportPdfCursorState,
@@ -165,7 +190,7 @@ export function buildMeasurementsChartsSection(
tableBottomMargin,
ensureSpace,
unitFor,
- vitalTypes,
+ vitalGroups,
} = context;
let y = state.y;
@@ -177,37 +202,22 @@ export function buildMeasurementsChartsSection(
doc.text(t("doctorReport.vitalsTitle"), margin, y);
y += 6;
- const vitalRows: string[][] = [];
-
- for (const type of vitalTypes) {
- const s = data.stats[type];
- if (!s) continue;
- const unit = unitFor(type);
- // SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
- // layer reconstructs them for exactly this row); the vitals unit is hours
- // (`h`), so convert to keep value and unit consistent — the same hours
- // value the FHIR sleep Observation emits.
- const conv =
- type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
- vitalRows.push([
- t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
- `${num(conv(s.latest))} ${unit}`.trim(),
- `${num(conv(s.avg))} ${unit}`.trim(),
- num(conv(s.min)),
- num(conv(s.max)),
- String(s.count),
- ]);
- }
+ // One sub-headed block per selection group, in the same order the panel
+ // shows them, carrying every selected leaf that has data. The aggregator has
+ // already applied the selection, so a leaf missing from `data.stats` is a
+ // leaf that was withheld or has no reading — the table cannot over-serve.
+ const vitalRows: VitalTableRow[] = [];
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
- // the user's display unit. Reference ranges come from the server-side
- // `getEffectiveRange()` (already baked into `data.glucoseRanges`).
+ // the user's display unit. They belong under the glucose group heading with
+ // the raw series, not appended after the whole table.
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
+ const glucoseContextRows: string[][] = [];
for (const ctx of GLUCOSE_CONTEXTS) {
const s = data.glucoseStats?.[ctx];
if (!s) continue;
const conv = (v: number) => convertGlucose(v, glucoseUnit);
- vitalRows.push([
+ glucoseContextRows.push([
t(GLUCOSE_LABEL_KEYS[ctx]),
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
@@ -217,6 +227,33 @@ export function buildMeasurementsChartsSection(
]);
}
+ for (const group of vitalGroups) {
+ const groupRows: string[][] = [];
+ for (const type of group.types) {
+ const s = data.stats[type];
+ if (!s) continue;
+ const unit = unitFor(type);
+ // SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
+ // layer reconstructs them for exactly this row); the vitals unit is
+ // hours (`h`), so convert to keep value and unit consistent — the same
+ // hours value the FHIR sleep Observation emits.
+ const conv =
+ type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
+ groupRows.push([
+ t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
+ `${num(conv(s.latest))} ${unit}`.trim(),
+ `${num(conv(s.avg))} ${unit}`.trim(),
+ num(conv(s.min)),
+ num(conv(s.max)),
+ String(s.count),
+ ]);
+ }
+ if (group.id === "glucose") groupRows.push(...glucoseContextRows);
+ if (groupRows.length === 0) continue;
+ vitalRows.push(groupSubHeadRow(t(group.labelKey)));
+ vitalRows.push(...groupRows);
+ }
+
if (vitalRows.length > 0) {
autoTable(doc, {
startY: y,
@@ -394,7 +431,9 @@ export function buildMeasurementsChartsSection(
// A chart never tears across a page boundary — break before drawing
// it whole.
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
- const label = t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? "");
+ const label = t(
+ MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "",
+ );
y = drawSparkline(doc, {
x: margin,
y,
diff --git a/src/lib/doctor-report-pdf/render-context.ts b/src/lib/doctor-report-pdf/render-context.ts
index f306f4d1b..f5f1c2d85 100644
--- a/src/lib/doctor-report-pdf/render-context.ts
+++ b/src/lib/doctor-report-pdf/render-context.ts
@@ -27,14 +27,22 @@ export interface DoctorReportPdfRenderContext {
now: Date;
insuranceNumber: string | null;
includeCharts: boolean;
- aiSummary: string | null;
footerTz: string;
margin: number;
pageWidth: number;
pageHeight: number;
contentMaxY: number;
tableBottomMargin: number;
- vitalTypes: readonly string[];
+ /**
+ * The measurement table's groups, in selection order. Each carries the
+ * group's label key and its measurement types; a group whose types produced
+ * no rows is skipped at render time.
+ */
+ vitalGroups: ReadonlyArray<{
+ id: string;
+ labelKey: string;
+ types: readonly string[];
+ }>;
unitFor: (type: string) => string;
ensureSpace: (current: number, needed: number) => number;
}
diff --git a/src/lib/doctor-report-selection.ts b/src/lib/doctor-report-selection.ts
deleted file mode 100644
index 5a18a2d9c..000000000
--- a/src/lib/doctor-report-selection.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * 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/doctor-report-types.ts b/src/lib/doctor-report-types.ts
index af5ea2204..9adf02d25 100644
--- a/src/lib/doctor-report-types.ts
+++ b/src/lib/doctor-report-types.ts
@@ -13,7 +13,6 @@
import type { GlucoseUnit } from "@/lib/glucose";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { MeasurementSource } from "@/generated/prisma/client";
-import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
import type { ModuleKey } from "@/lib/modules/gate";
import type {
ComplianceSchedule,
@@ -344,26 +343,17 @@ export interface DoctorReportRange {
export interface CollectDoctorReportOptions {
/** Free-text practice/clinic name to print on the cover. Optional. */
practiceName?: string | null;
- /**
- * v1.4.25 W6c — per-section visibility toggles. When omitted the
- * documented defaults apply (every section ON except mood). When
- * `sections.mood = false` the aggregator does NOT query
- * `MoodEntry` at all — the mood data never leaves the DB row
- * (privacy-by-default). Other sections are stripped from the
- * returned payload so downstream renderers (PDF + JSON consumer)
- * don't accidentally print a section the user opted out of.
- */
- sections?: Partial;
/**
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
- * module forces its report section / FHIR resources out of the payload,
- * regardless of the user's per-report `sections` toggles: a user who turned
- * off the Sleep module exports no sleep series, mood Observations, glucose
- * panel, recovery/strain scores, cycle summary, or lab results for the
- * modules they no longer keep. Core clinical sections (weight / BP / pulse /
- * medications) carry no module key and are always included. Injectable for
- * tests so the module ↔ section mapping can be asserted without a DB.
+ * module forces its leaves out of the payload regardless of the selection: a
+ * user who turned off the Sleep module exports no sleep series, whatever the
+ * report asked for. Injectable for tests so the module ↔ leaf mapping can be
+ * asserted without a DB.
+ *
+ * The report SCOPE is not an option — it is the required third parameter of
+ * `collectDoctorReportData`, so a new egress path cannot be written without
+ * naming where its selection came from.
*/
moduleMap?: Record;
}
diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts
new file mode 100644
index 000000000..4142317f4
--- /dev/null
+++ b/src/lib/doctor-report/clinical-records.ts
@@ -0,0 +1,157 @@
+/**
+ * The structured clinical records on the doctor report: labs, illness
+ * episodes, allergies and family history.
+ *
+ * Each one is its own leaf and its own read. A refused leaf is not queried at
+ * all — the payload reflects "this was never fetched", not "this was fetched
+ * and then dropped". No free-text note column is ever selected on this path;
+ * the only decrypted free text is the allergy reaction, which is what an
+ * allergy record is for.
+ *
+ * Split out of the aggregator with the selection rework.
+ */
+import { prisma } from "@/lib/db";
+import { getEvent } from "@/lib/logging/context";
+import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
+import type { DoctorReportData } from "@/lib/doctor-report-types";
+
+/**
+ * Structured lab results over the window, reduced to the latest reading per
+ * analyte with a count, so the report is a concise panel rather than a raw
+ * dump. Analyte keys fold case-insensitively.
+ */
+export async function loadLabResults(
+ userId: string,
+ start: Date,
+ end: Date,
+): Promise {
+ const rows = await prisma.labResult.findMany({
+ where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
+ orderBy: { takenAt: "asc" },
+ select: {
+ panel: true,
+ analyte: true,
+ value: true,
+ valueText: true,
+ unit: true,
+ referenceLow: true,
+ referenceHigh: true,
+ takenAt: true,
+ },
+ });
+ const byAnalyte = new Map<
+ string,
+ NonNullable[number]
+ >();
+ for (const r of rows) {
+ const key = r.analyte.toLowerCase();
+ const prev = byAnalyte.get(key);
+ byAnalyte.set(key, {
+ panel: r.panel,
+ analyte: r.analyte,
+ value: r.value,
+ valueText: r.valueText,
+ unit: r.unit,
+ referenceLow: r.referenceLow,
+ referenceHigh: r.referenceHigh,
+ takenAt: r.takenAt.toISOString(),
+ count: (prev?.count ?? 0) + 1,
+ });
+ }
+ const collapsed = Array.from(byAnalyte.values());
+ return collapsed.length > 0 ? collapsed : null;
+}
+
+/**
+ * Illness / condition episodes overlapping the window. An episode overlaps
+ * when it began on or before the window end AND is either still ongoing or
+ * resolved on or after the window start. Labels, lifecycle and dates only.
+ */
+export async function loadIllnessEpisodes(
+ userId: string,
+ start: Date,
+ end: Date,
+): Promise {
+ const rows = await prisma.illnessEpisode.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ onsetAt: { lte: end },
+ OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }],
+ },
+ orderBy: { onsetAt: "asc" },
+ select: {
+ label: true,
+ type: true,
+ lifecycle: true,
+ onsetAt: true,
+ resolvedAt: true,
+ },
+ });
+ const mapped = rows.map((e) => ({
+ label: e.label,
+ type: e.type,
+ lifecycle: e.lifecycle,
+ onsetAt: e.onsetAt.toISOString(),
+ resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
+ }));
+ return mapped.length > 0 ? mapped : null;
+}
+
+/**
+ * Structured allergy records. Reference data, not time-windowed — a
+ * penicillin allergy does not expire with the report window.
+ *
+ * The reaction description decrypts fail-soft per row. A reaction that WAS
+ * recorded but cannot be read is flagged rather than silently blanked, so the
+ * renderer prints an honest marker instead of a clinician-facing export
+ * reading as "no reaction recorded".
+ */
+export async function loadAllergies(
+ userId: string,
+): Promise {
+ const rows = await prisma.allergy.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { createdAt: "asc" },
+ select: {
+ substance: true,
+ category: true,
+ type: true,
+ severity: true,
+ status: true,
+ reactionEncrypted: true,
+ },
+ });
+ const mapped = rows.map((r) => {
+ const { reaction, reactionUnreadable } = decryptAllergyReaction(
+ r.reactionEncrypted,
+ );
+ if (reactionUnreadable) {
+ getEvent()?.addWarning(
+ `doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`,
+ );
+ }
+ return {
+ substance: r.substance,
+ category: r.category,
+ type: r.type,
+ severity: r.severity,
+ status: r.status,
+ reaction,
+ reactionUnreadable,
+ };
+ });
+ return mapped.length > 0 ? mapped : null;
+}
+
+/** Structured family history. Same stance as allergies: reference data. */
+export async function loadFamilyHistory(
+ userId: string,
+): Promise {
+ const rows = await prisma.familyHistoryEntry.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { createdAt: "asc" },
+ select: { relationship: true, condition: true, ageAtOnset: true },
+ });
+ return rows.length > 0 ? rows : null;
+}
diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts
new file mode 100644
index 000000000..fb0b0dcee
--- /dev/null
+++ b/src/lib/doctor-report/collect.ts
@@ -0,0 +1,577 @@
+/**
+ * Server-side aggregator for doctor-report data.
+ *
+ * Single source of truth for the payload every output path reads: the PDF, the
+ * FHIR document bundle, the package zip, the clinician share view, the FHIR
+ * REST face and the MCP doctor-visit surfaces. A leaf excluded here is absent
+ * from `measurements`, from `stats` and from the structured blocks, so every
+ * renderer describes the same record by construction rather than by each one
+ * remembering to apply the same filter.
+ *
+ * The third parameter is a REQUIRED, brand-typed {@link ReportSelection}. It
+ * used to be optional and default to everything-on, which meant a new egress
+ * path could be written without anyone answering "whose selection?". Now it
+ * cannot compile without an answer.
+ *
+ * Pure data assembly — no auth, no rate limit, no audit. Idempotent.
+ */
+import { prisma } from "@/lib/db";
+import {
+ getEffectiveRange,
+ type ThresholdOverridesJson,
+} from "@/lib/analytics/effective-range";
+import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
+import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import type {
+ GlucoseContext,
+ MeasurementType,
+} from "@/generated/prisma/client";
+import {
+ reconstructSleepNights,
+ type SleepStageRow,
+} from "@/lib/analytics/sleep-night";
+import { buildCycleExportSummary } from "@/lib/cycle/export-data";
+import { resolveModuleMap } from "@/lib/modules/gate";
+import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
+import { MEASUREMENT_LEAF_IDS } from "@/lib/report-selection/catalogue";
+import type { ReportSelection } from "@/lib/report-selection/selection";
+import {
+ WELLNESS_SCORE_REPORT_TYPES,
+ type CollectDoctorReportOptions,
+ type DoctorReportData,
+ type DoctorReportMood,
+ type DoctorReportRange,
+ type DoctorReportStats,
+} from "@/lib/doctor-report-types";
+import {
+ buildLedgerCompliance,
+ collapseMeasurementsToCanonical,
+ minMaxOf,
+ sanitisePracticeName,
+ summariseCanonicalRecovery,
+} from "@/lib/doctor-report-helpers";
+import {
+ excludedMeasurementTypes,
+ filterMeasurementKeys,
+ resolveReportGate,
+} from "./selection-gate";
+import { loadDenseMeasurementBuckets } from "./dense-buckets";
+import { summariseDenseBuckets } from "./measurement-series";
+import { emptyGlucoseClinical } from "./glucose-panel";
+import { buildAdministrationLedger, buildGlp1Block } from "./medications";
+import {
+ loadAllergies,
+ loadFamilyHistory,
+ loadIllnessEpisodes,
+ loadLabResults,
+} from "./clinical-records";
+
+const GLUCOSE_CONTEXTS: GlucoseContext[] = [
+ "FASTING",
+ "POSTPRANDIAL",
+ "RANDOM",
+ "BEDTIME",
+];
+
+const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
+
+/** No medication row is read unless at least one medication leaf is admitted. */
+const MEDICATION_LEAVES = [
+ "MEDICATION_LIST",
+ "MEDICATION_ADMINISTRATIONS",
+ "MEDICATION_COMPLIANCE",
+ "GLP1_THERAPY",
+] as const;
+
+export async function collectDoctorReportData(
+ userId: string,
+ range: DoctorReportRange,
+ selection: ReportSelection,
+ options: CollectDoctorReportOptions = {},
+): Promise {
+ const { start, end, days } = range;
+
+ // Resolve the per-user module map once. A disabled module wins over a
+ // selected leaf; a deselected leaf wins over an enabled module. Injectable
+ // for tests.
+ const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId));
+
+ // v1.30.22 — fail-closed backstop on the aggregate's OWN module key.
+ //
+ // Everything below gates individual leaves, but nothing ever gated
+ // `doctorReport` itself — the key that decides whether the whole-record
+ // aggregate may be assembled at all. REFUSE, not omit: this is the
+ // whole-record aggregate, so there is no honest partial answer. It throws
+ // rather than returning an envelope because reaching here means a caller is
+ // missing its gate — a bug to surface, not a flow to serve. Callers that can
+ // degrade gracefully gate themselves BEFORE calling.
+ if (moduleMap.doctorReport === false) {
+ throw new Error(
+ 'collectDoctorReportData called with the "doctorReport" module ' +
+ "disabled — the calling surface is missing its module gate",
+ );
+ }
+
+ const gate = resolveReportGate(selection, moduleMap);
+
+ // Types read BEFORE the output filter because something else is derived from
+ // them. Weight feeds the BMI figure and the GLP-1 weight delta; the raw
+ // glucose series feeds the clinical panel. Each derived block is gated by its
+ // own leaf, so keeping the rows here widens nothing that leaves the function.
+ const keepForDerived = new Set();
+ if (gate.admits("BODY_MASS_INDEX") || gate.admits("GLP1_THERAPY")) {
+ keepForDerived.add("WEIGHT");
+ }
+ if (gate.admits("GLUCOSE_PANEL")) keepForDerived.add("BLOOD_GLUCOSE");
+
+ const excluded = excludedMeasurementTypes(
+ gate,
+ MEASUREMENT_LEAF_IDS,
+ keepForDerived,
+ );
+
+ const userProfile = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ username: true,
+ dateOfBirth: true,
+ gender: true,
+ heightCm: true,
+ glucoseUnit: true,
+ thresholdsJson: true,
+ timezone: true,
+ sourcePriorityJson: true,
+ fullName: true,
+ insurerName: true,
+ insurerIkNumber: true,
+ },
+ });
+ const reportTz = userProfile?.timezone ?? "Europe/Berlin";
+
+ const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
+ const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
+ const denseGlucose =
+ aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
+ const rawExcluded = [...excluded];
+ if (densePulse) rawExcluded.push("PULSE");
+ if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE");
+
+ const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf));
+ const wantsIntakeEvents =
+ gate.admits("MEDICATION_ADMINISTRATIONS") ||
+ gate.admits("MEDICATION_COMPLIANCE") ||
+ gate.admits("GLP1_THERAPY");
+
+ const [measurements, medications, intakeEvents, moodEntries, denseBuckets] =
+ await Promise.all([
+ prisma.measurement.findMany({
+ // Soft-deleted rows never reach the report.
+ where: {
+ userId,
+ measuredAt: { gte: start, lte: end },
+ deletedAt: null,
+ ...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
+ },
+ orderBy: { measuredAt: "asc" },
+ // Narrow select: the collector reads exactly these seven fields.
+ // The full-width read pulled every column — including the encrypted
+ // notes — across up to 730 days of rows.
+ select: {
+ type: true,
+ value: true,
+ measuredAt: true,
+ source: true,
+ deviceType: true,
+ sleepStage: true,
+ glucoseContext: true,
+ },
+ }),
+ wantsMedications
+ ? prisma.medication.findMany({
+ where: { userId, active: true },
+ include: {
+ schedules: {
+ select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true },
+ },
+ scheduleRevisions: {
+ orderBy: { validFrom: "asc" },
+ select: {
+ id: true,
+ validFrom: true,
+ validUntil: true,
+ payload: true,
+ supersededByRevisionId: true,
+ },
+ },
+ pauseEras: { select: { pausedAt: true, resumedAt: true } },
+ doseChanges: { orderBy: { effectiveFrom: "asc" } },
+ intakeEvents: {
+ where: { takenAt: { not: null } },
+ orderBy: { takenAt: "desc" },
+ take: 1,
+ select: { takenAt: true, injectionSite: true },
+ },
+ },
+ })
+ : Promise.resolve([]),
+ wantsIntakeEvents
+ ? prisma.medicationIntakeEvent.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ scheduledFor: { gte: start, lte: end },
+ },
+ include: {
+ medication: {
+ select: {
+ id: true,
+ name: true,
+ dose: true,
+ atcCode: true,
+ rxNormCode: true,
+ deliveryForm: true,
+ asNeeded: true,
+ },
+ },
+ },
+ orderBy: { scheduledFor: "asc" },
+ })
+ : Promise.resolve([]),
+ // Zero DB read when the mood leaf was not chosen: the payload reflects
+ // "mood was never fetched", not "mood was fetched and then dropped".
+ gate.admits("MOOD")
+ ? prisma.moodEntry.findMany({
+ where: {
+ userId,
+ deletedAt: null,
+ moodLoggedAt: { gte: start, lte: end },
+ },
+ orderBy: { moodLoggedAt: "asc" },
+ // Two fields: the score (summary + distribution) and the tags
+ // (GLP-1 side-effect tallies). Never the note columns.
+ select: { score: true, tags: true },
+ })
+ : Promise.resolve([]),
+ loadDenseMeasurementBuckets({
+ userId,
+ start,
+ end,
+ reportTz,
+ densePulse,
+ denseGlucose,
+ }),
+ ]);
+
+ const denseSummary = summariseDenseBuckets(
+ denseBuckets,
+ reportTz,
+ userProfile?.sourcePriorityJson ?? null,
+ days,
+ );
+
+ // Collapse each multi-source metric to its CANONICAL source before grouping,
+ // so the per-type avg/min/max match the dashboard rather than blending
+ // overlapping sources.
+ const canonicalMeasurements = collapseMeasurementsToCanonical(
+ measurements,
+ reportTz,
+ userProfile?.sourcePriorityJson ?? null,
+ );
+
+ const byType: Record<
+ string,
+ Array<{ value: number; measuredAt: string }>
+ > = {};
+ for (const m of canonicalMeasurements) {
+ if (!byType[m.type]) byType[m.type] = [];
+ byType[m.type].push({
+ value: m.value,
+ measuredAt: m.measuredAt.toISOString(),
+ });
+ }
+ for (const [type, entries] of Object.entries(denseSummary.byType)) {
+ byType[type] = entries;
+ }
+ for (const entries of Object.values(byType)) {
+ entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
+ }
+
+ // SLEEP_DURATION enters `byType` as RAW per-stage rows. Every other sleep
+ // surface shows the per-night reconstructed asleep total, so route the
+ // report's sleep value through the same engine — one number, one surface.
+ const sleepRows = measurements.filter(
+ (m) => m.type === "SLEEP_DURATION",
+ ) as unknown as SleepStageRow[];
+ if (sleepRows.length > 0) {
+ const nights = reconstructSleepNights(
+ sleepRows,
+ reportTz,
+ userProfile?.sourcePriorityJson ?? null,
+ ).filter((n) => n.asleepMinutes > 0);
+ if (nights.length > 0) {
+ byType.SLEEP_DURATION = nights.map((n) => ({
+ value: n.asleepMinutes,
+ measuredAt: n.measuredAt.toISOString(),
+ }));
+ } else {
+ delete byType.SLEEP_DURATION;
+ }
+ }
+
+ const stats: Record = {};
+ for (const [type, entries] of Object.entries(byType)) {
+ const values = entries.map((e) => e.value);
+ stats[type] = {
+ avg: values.reduce((a, b) => a + b, 0) / values.length,
+ ...minMaxOf(values),
+ count: values.length,
+ latest: values[values.length - 1],
+ };
+ }
+ Object.assign(stats, denseSummary.stats);
+
+ // Medication compliance through the dose-ledger authority — the same engine
+ // the detail page uses, not a raw-row tally. As-needed medications are
+ // excluded: no schedule, no expected dose, no fabricated 100 % on a
+ // clinical report. The medication itself stays on the list.
+ const compliance = buildLedgerCompliance(
+ medications.map((m) => ({
+ id: m.id,
+ name: m.name,
+ asNeeded: m.asNeeded,
+ startsOn: m.startsOn,
+ endsOn: m.endsOn,
+ oneShot: m.oneShot,
+ createdAt: m.createdAt,
+ schedules: m.schedules,
+ scheduleRevisions: m.scheduleRevisions,
+ pauseEras: m.pauseEras,
+ })),
+ intakeEvents.map((e) => ({
+ medicationId: e.medicationId,
+ scheduledFor: e.scheduledFor,
+ takenAt: e.takenAt,
+ skipped: e.skipped,
+ autoMissed: e.autoMissed ?? false,
+ attributionSource: e.attributionSource ?? undefined,
+ })),
+ reportTz,
+ start,
+ end,
+ end,
+ );
+
+ const ledger = buildAdministrationLedger(medications, intakeEvents);
+
+ const moodScores = moodEntries.map((e) => e.score);
+ const mood: DoctorReportMood | null =
+ moodScores.length > 0
+ ? {
+ avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
+ min: minMaxOf(moodScores).min,
+ max: minMaxOf(moodScores).max,
+ count: moodScores.length,
+ distribution: {
+ 1: moodScores.filter((s) => s === 1).length,
+ 2: moodScores.filter((s) => s === 2).length,
+ 3: moodScores.filter((s) => s === 3).length,
+ 4: moodScores.filter((s) => s === 4).length,
+ 5: moodScores.filter((s) => s === 5).length,
+ },
+ }
+ : null;
+
+ // BMI from the latest weight and the profile height. Gated by its own leaf,
+ // which is also the leaf that gates any recorded BODY_MASS_INDEX rows — one
+ // control for the figure and the series alike.
+ const weightStats = stats.WEIGHT;
+ const bmiRaw =
+ weightStats && userProfile?.heightCm
+ ? weightStats.latest / (userProfile.heightCm / 100) ** 2
+ : null;
+ const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
+
+ // Per-context glucose stats + effective ranges (canonical mg/dL). The whole
+ // panel rides the GLUCOSE_PANEL leaf: stats, ranges and clinical metrics
+ // collapse together, so the artefact never carries half of it.
+ const glucosePanelOn = gate.admits("GLUCOSE_PANEL");
+ const glucoseStats: Record =
+ glucosePanelOn && denseGlucose ? { ...denseSummary.glucoseStats } : {};
+ const glucoseRanges: Record = {};
+ const glucoseRows = glucosePanelOn
+ ? measurements.filter((m) => m.type === "BLOOD_GLUCOSE")
+ : [];
+ const overrides = (userProfile?.thresholdsJson ??
+ null) as ThresholdOverridesJson | null;
+ const profileForRange = {
+ heightCm: userProfile?.heightCm ?? null,
+ dateOfBirth: userProfile?.dateOfBirth ?? null,
+ gender: userProfile?.gender ?? null,
+ };
+ if (glucosePanelOn) {
+ for (const ctx of GLUCOSE_CONTEXTS) {
+ if (!denseGlucose) {
+ const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
+ if (rows.length === 0) continue;
+ const values = rows.map((r) => r.value);
+ glucoseStats[ctx] = {
+ avg: values.reduce((a, b) => a + b, 0) / values.length,
+ ...minMaxOf(values),
+ count: values.length,
+ latest: values[values.length - 1],
+ };
+ } else if (!glucoseStats[ctx]) {
+ continue;
+ }
+ const eff = getEffectiveRange(
+ thresholdMetricForContext(ctx),
+ profileForRange,
+ overrides,
+ );
+ if (eff.range) {
+ glucoseRanges[ctx] = {
+ min: eff.range.greenMin,
+ max: eff.range.greenMax,
+ };
+ }
+ }
+ }
+
+ // The clinical panel over the whole report period, all contexts pooled.
+ // `windowDays` is the report's own period so TIR / GMI / eA1C / CV% reflect
+ // exactly the readings the rest of the report tabulates.
+ const glucoseClinical = !glucosePanelOn
+ ? emptyGlucoseClinical(days)
+ : denseGlucose
+ ? denseSummary.glucoseClinical
+ : computeGlucoseClinicalMetrics(
+ glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
+ { windowDays: days, now: end },
+ );
+
+ const practiceName = sanitisePracticeName(options.practiceName);
+
+ // The output half of the query gate, reading the same gate object.
+ const filteredByType = filterMeasurementKeys(byType, gate);
+ const filteredStats = filterMeasurementKeys(stats, gate);
+
+ const glp1 = gate.admits("GLP1_THERAPY")
+ ? buildGlp1Block({
+ medications,
+ compliance,
+ weightSeries: byType.WEIGHT ?? [],
+ moodTagRows: moodEntries,
+ })
+ : null;
+
+ // The wellness summary is filtered to the same score leaves the stats carry,
+ // so it cannot disagree with them. RECOVERY_SCORE carries both the native row
+ // and the computed proxy for one day; resolve to the canonical row so the PDF
+ // reads the SAME value the tile shows.
+ const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
+ if (!gate.admits(type)) return [];
+ if (type === "RECOVERY_SCORE") {
+ const summary = summariseCanonicalRecovery(measurements, reportTz);
+ return summary ? [summary] : [];
+ }
+ const s = stats[type];
+ const rows = byType[type];
+ if (!s || !rows || rows.length === 0) return [];
+ return [
+ {
+ type,
+ latest: Math.round(s.latest),
+ avg: Math.round(s.avg),
+ min: Math.round(s.min),
+ max: Math.round(s.max),
+ count: s.count,
+ latestAt: rows[rows.length - 1].measuredAt,
+ },
+ ];
+ });
+ const wellnessScores =
+ wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
+
+ // Each remaining structured block: read only when its leaf was chosen.
+ const [cycle, labResults, illnessEpisodes, allergies, familyHistory] =
+ await Promise.all([
+ gate.admits("CYCLE")
+ ? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
+ : Promise.resolve(null),
+ gate.admits("LAB_RESULTS")
+ ? loadLabResults(userId, start, end)
+ : Promise.resolve(null),
+ gate.admits("ILLNESS_EPISODES")
+ ? loadIllnessEpisodes(userId, start, end)
+ : Promise.resolve(null),
+ gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
+ gate.admits("FAMILY_HISTORY")
+ ? loadFamilyHistory(userId)
+ : Promise.resolve(null),
+ ]);
+
+ const identityOn = gate.admits("PATIENT_IDENTITY");
+ const insuranceOn = gate.admits("INSURANCE");
+
+ return {
+ period: {
+ days,
+ // `since` mirrors `start`, preserved for in-flight clients.
+ since: start.toISOString(),
+ start: start.toISOString(),
+ end: end.toISOString(),
+ },
+ patient: {
+ username: identityOn ? (userProfile?.username ?? null) : null,
+ dateOfBirth:
+ identityOn && userProfile?.dateOfBirth
+ ? userProfile.dateOfBirth.toISOString()
+ : null,
+ gender: identityOn ? (userProfile?.gender ?? null) : null,
+ heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
+ fullName: identityOn ? (userProfile?.fullName ?? null) : null,
+ insurerName: insuranceOn ? (userProfile?.insurerName ?? null) : null,
+ insurerIkNumber: insuranceOn
+ ? (userProfile?.insurerIkNumber ?? null)
+ : null,
+ },
+ practiceName,
+ measurements: filteredByType,
+ stats: filteredStats,
+ glucoseStats,
+ glucoseRanges,
+ glucoseClinical,
+ glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
+ bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
+ compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
+ medications: gate.admits("MEDICATION_LIST")
+ ? medications.map((m) => ({
+ name: m.name,
+ dose: m.dose,
+ atcCode: m.atcCode,
+ rxNormCode: m.rxNormCode,
+ schedules: m.schedules.map((s) => ({
+ windowStart: s.windowStart,
+ windowEnd: s.windowEnd,
+ label: s.label,
+ })),
+ }))
+ : [],
+ medicationAdministrations: gate.admits("MEDICATION_ADMINISTRATIONS")
+ ? ledger.administrations
+ : [],
+ medicationAdministrationsTruncation: gate.admits(
+ "MEDICATION_ADMINISTRATIONS",
+ )
+ ? ledger.truncation
+ : null,
+ mood,
+ glp1,
+ wellnessScores,
+ cycle,
+ labResults,
+ illnessEpisodes,
+ allergies,
+ familyHistory,
+ };
+}
diff --git a/src/lib/doctor-report/dense-buckets.ts b/src/lib/doctor-report/dense-buckets.ts
new file mode 100644
index 000000000..597b55349
--- /dev/null
+++ b/src/lib/doctor-report/dense-buckets.ts
@@ -0,0 +1,211 @@
+/**
+ * The per-local-day pre-aggregation for the two sample-dense measurement
+ * types.
+ *
+ * On a long window a CGM or a per-sample heart-rate account produces six
+ * figures of rows, so pulse and glucose collapse to one row per local
+ * day/source/device/context in SQL and the canonical-source picker then works
+ * over that bounded intermediate set. Every other type keeps the raw-row path.
+ *
+ * Extracted from the aggregator with the selection rework: the query is a job
+ * of its own, it is the one raw-SQL surface on this path, and keeping it in a
+ * thousand-line function is how a parameter stops being reviewed.
+ */
+import { prisma } from "@/lib/db";
+import type {
+ GlucoseContext,
+ MeasurementSource,
+ MeasurementType,
+} from "@/generated/prisma/client";
+
+/** One pre-aggregated bucket: a local day for one type/source/device/context. */
+export interface DenseMeasurementBucket {
+ bucketStart: Date;
+ type: MeasurementType;
+ source: MeasurementSource;
+ deviceType: string | null;
+ glucoseContext: GlucoseContext | null;
+ count: number;
+ sumValue: number;
+ minValue: number;
+ maxValue: number;
+ latestAt: Date;
+ latestValue: number;
+ clinicalCount: number;
+ clinicalSum: number | null;
+ clinicalSumSquares: number | null;
+ clinicalFirstAt: Date | null;
+ clinicalLastAt: Date | null;
+ clinicalTirCount: number;
+ clinicalTbr1Count: number;
+ clinicalTbr2Count: number;
+ clinicalTar1Count: number;
+ clinicalTar2Count: number;
+ clinicalLowRiskSum: number | null;
+ clinicalHighRiskSum: number | null;
+}
+
+/**
+ * Load the dense buckets for whichever of the two types the caller wants. Both
+ * flags false is not a call: the caller skips it entirely.
+ *
+ * Every value is bound as a tagged-template parameter — no splice, no
+ * interpolation of anything that came off the wire.
+ */
+export async function loadDenseMeasurementBuckets(params: {
+ userId: string;
+ start: Date;
+ end: Date;
+ reportTz: string;
+ densePulse: boolean;
+ denseGlucose: boolean;
+}): Promise {
+ const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
+ if (!densePulse && !denseGlucose) return [];
+ return prisma.$queryRaw`
+ WITH localized AS (
+ SELECT
+ m."id",
+ m."type",
+ m."source",
+ m."device_type",
+ m."glucose_context",
+ m."value",
+ m."measured_at",
+ date_trunc(
+ 'day',
+ (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
+ ) AS local_day
+ FROM measurements m
+ WHERE m."user_id" = ${userId}
+ AND m."measured_at" >= ${start}
+ AND m."measured_at" <= ${end}
+ AND m."deleted_at" IS NULL
+ AND (
+ (${densePulse} AND m."type" = 'PULSE'::"measurement_type")
+ OR
+ (${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
+ )
+ ),
+ scored AS (
+ SELECT
+ localized.*,
+ CASE
+ WHEN "value" > 0
+ THEN 1.509 * (power(ln("value"), 1.084) - 5.381)
+ ELSE NULL
+ END AS risk_transform
+ FROM localized
+ ),
+ latest_rows AS (
+ SELECT DISTINCT ON (
+ local_day,
+ "type",
+ "source",
+ "device_type",
+ "glucose_context"
+ )
+ local_day,
+ "type",
+ "source",
+ "device_type",
+ "glucose_context",
+ "measured_at" AS latest_at,
+ "value" AS latest_value
+ FROM localized
+ ORDER BY
+ local_day,
+ "type",
+ "source",
+ "device_type",
+ "glucose_context",
+ "measured_at" DESC,
+ "id" DESC
+ ),
+ grouped AS (
+ SELECT
+ local_day,
+ "type",
+ "source",
+ "device_type",
+ "glucose_context",
+ COUNT(*)::int AS sample_count,
+ SUM("value")::double precision AS sum_value,
+ MIN("value")::double precision AS min_value,
+ MAX("value")::double precision AS max_value,
+ COUNT(*) FILTER (WHERE "value" > 0)::int AS clinical_count,
+ SUM("value") FILTER (WHERE "value" > 0)::double precision
+ AS clinical_sum,
+ SUM("value" * "value") FILTER (WHERE "value" > 0)::double precision
+ AS clinical_sum_squares,
+ MIN("measured_at") FILTER (WHERE "value" > 0) AS clinical_first_at,
+ MAX("measured_at") FILTER (WHERE "value" > 0) AS clinical_last_at,
+ COUNT(*) FILTER (WHERE "value" BETWEEN 70 AND 180)::int
+ AS clinical_tir_count,
+ COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 70)::int
+ AS clinical_tbr1_count,
+ COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 54)::int
+ AS clinical_tbr2_count,
+ COUNT(*) FILTER (WHERE "value" > 180)::int
+ AS clinical_tar1_count,
+ COUNT(*) FILTER (WHERE "value" > 250)::int
+ AS clinical_tar2_count,
+ SUM(
+ CASE
+ WHEN risk_transform < 0
+ THEN 10 * risk_transform * risk_transform
+ ELSE 0
+ END
+ ) FILTER (WHERE risk_transform IS NOT NULL)::double precision
+ AS clinical_low_risk_sum,
+ SUM(
+ CASE
+ WHEN risk_transform > 0
+ THEN 10 * risk_transform * risk_transform
+ ELSE 0
+ END
+ ) FILTER (WHERE risk_transform IS NOT NULL)::double precision
+ AS clinical_high_risk_sum
+ FROM scored
+ GROUP BY
+ local_day,
+ "type",
+ "source",
+ "device_type",
+ "glucose_context"
+ )
+ SELECT
+ grouped.local_day AT TIME ZONE ${reportTz} AS "bucketStart",
+ grouped."type",
+ grouped."source",
+ grouped."device_type" AS "deviceType",
+ grouped."glucose_context" AS "glucoseContext",
+ grouped.sample_count AS "count",
+ grouped.sum_value AS "sumValue",
+ grouped.min_value AS "minValue",
+ grouped.max_value AS "maxValue",
+ latest_rows.latest_at AS "latestAt",
+ latest_rows.latest_value::double precision AS "latestValue",
+ grouped.clinical_count AS "clinicalCount",
+ grouped.clinical_sum AS "clinicalSum",
+ grouped.clinical_sum_squares AS "clinicalSumSquares",
+ grouped.clinical_first_at AS "clinicalFirstAt",
+ grouped.clinical_last_at AS "clinicalLastAt",
+ grouped.clinical_tir_count AS "clinicalTirCount",
+ grouped.clinical_tbr1_count AS "clinicalTbr1Count",
+ grouped.clinical_tbr2_count AS "clinicalTbr2Count",
+ grouped.clinical_tar1_count AS "clinicalTar1Count",
+ grouped.clinical_tar2_count AS "clinicalTar2Count",
+ grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
+ grouped.clinical_high_risk_sum AS "clinicalHighRiskSum"
+ FROM grouped
+ INNER JOIN latest_rows
+ ON latest_rows.local_day = grouped.local_day
+ AND latest_rows."type" = grouped."type"
+ AND latest_rows."source" = grouped."source"
+ AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
+ AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
+ ORDER BY "bucketStart" ASC, grouped."type" ASC
+
+`;
+}
diff --git a/src/lib/doctor-report/glucose-panel.ts b/src/lib/doctor-report/glucose-panel.ts
new file mode 100644
index 000000000..ee417fee2
--- /dev/null
+++ b/src/lib/doctor-report/glucose-panel.ts
@@ -0,0 +1,146 @@
+/**
+ * The glucose clinical panel derived from pre-aggregated buckets.
+ *
+ * TIR / GMI / eA1C / CV% over the whole report period, computed by the same
+ * literature-locked engine the insights panel and the coach consume. The
+ * learning gate keeps a thin period from asserting a clinical picture off spot
+ * data: too few readings, or too short a span, and the panel says so rather
+ * than producing a number that reads as authoritative.
+ *
+ * Split out of the aggregator with the selection rework — it is a self-
+ * contained numeric job with its own literature behind it.
+ */
+import {
+ CV_INSTABILITY_THRESHOLD,
+ estimatedA1c,
+ gmi,
+ type GlucoseClinicalMetrics,
+} from "@/lib/analytics/glucose-metrics";
+import type { DenseMeasurementBucket } from "./dense-buckets";
+
+export function emptyGlucoseClinical(
+ windowDays: number,
+): GlucoseClinicalMetrics {
+ return {
+ stillLearning: true,
+
+ stillLearningReason: `No glucose readings in the last ${windowDays} days.`,
+ windowDays,
+ actualSpanDays: 0,
+ readingCount: 0,
+ meanMgdl: null,
+ distribution: null,
+ gmi: null,
+ estimatedA1c: null,
+ variability: null,
+ advanced: null,
+ isSpotEstimate: true,
+ };
+}
+
+export function glucoseClinicalFromBuckets(
+ buckets: readonly DenseMeasurementBucket[],
+ windowDays: number,
+): GlucoseClinicalMetrics {
+ let count = 0;
+ let sum = 0;
+ let sumSquares = 0;
+ let firstAt: Date | null = null;
+ let lastAt: Date | null = null;
+ let tir = 0;
+ let tbr1 = 0;
+ let tbr2 = 0;
+ let tar1 = 0;
+ let tar2 = 0;
+ let lowRisk = 0;
+ let highRisk = 0;
+
+ for (const bucket of buckets) {
+ if (bucket.type !== "BLOOD_GLUCOSE" || bucket.clinicalCount === 0) {
+ continue;
+ }
+ count += bucket.clinicalCount;
+ sum += bucket.clinicalSum ?? 0;
+ sumSquares += bucket.clinicalSumSquares ?? 0;
+ tir += bucket.clinicalTirCount;
+ tbr1 += bucket.clinicalTbr1Count;
+ tbr2 += bucket.clinicalTbr2Count;
+ tar1 += bucket.clinicalTar1Count;
+ tar2 += bucket.clinicalTar2Count;
+ lowRisk += bucket.clinicalLowRiskSum ?? 0;
+ highRisk += bucket.clinicalHighRiskSum ?? 0;
+ if (
+ bucket.clinicalFirstAt &&
+ (!firstAt || bucket.clinicalFirstAt < firstAt)
+ ) {
+ firstAt = bucket.clinicalFirstAt;
+ }
+ if (bucket.clinicalLastAt && (!lastAt || bucket.clinicalLastAt > lastAt)) {
+ lastAt = bucket.clinicalLastAt;
+ }
+ }
+
+ if (count === 0 || !firstAt || !lastAt) {
+ return emptyGlucoseClinical(windowDays);
+ }
+
+ const meanMgdl = sum / count;
+ const actualSpanDays =
+ (lastAt.getTime() - firstAt.getTime()) / (24 * 60 * 60 * 1000);
+ const variance =
+ count >= 2
+ ? Math.max(0, (sumSquares - (sum * sum) / count) / (count - 1))
+ : null;
+ const sd = variance === null ? null : Math.sqrt(variance);
+ const cv = sd === null || meanMgdl === 0 ? null : (sd / meanMgdl) * 100;
+ const fraction = (n: number) => n / count;
+ const minutes = (n: number) => fraction(n) * 1440;
+ const roundedSpan = Math.max(0, Math.round(actualSpanDays));
+ let stillLearningReason: string | null = null;
+ if (count < 14) {
+ stillLearningReason = `Still learning — ${count} reading${
+ count === 1 ? "" : "s"
+ } over ${roundedSpan} day${
+ roundedSpan === 1 ? "" : "s"
+ }; at least 14 are needed for a meaningful estimate.`;
+ } else if (actualSpanDays < 7) {
+ stillLearningReason = `Still learning — readings span only ${roundedSpan} day${
+ roundedSpan === 1 ? "" : "s"
+ }; at least 7 days of coverage are needed.`;
+ }
+
+ return {
+ stillLearning: stillLearningReason !== null,
+ stillLearningReason,
+ windowDays,
+ actualSpanDays,
+ readingCount: count,
+ meanMgdl,
+ distribution: {
+ tir: fraction(tir),
+ tbrLevel1: fraction(tbr1),
+ tbrLevel2: fraction(tbr2),
+ tarLevel1: fraction(tar1),
+ tarLevel2: fraction(tar2),
+ minutesEquivalent: {
+ tir: minutes(tir),
+ tbrLevel1: minutes(tbr1),
+ tbrLevel2: minutes(tbr2),
+ tarLevel1: minutes(tar1),
+ tarLevel2: minutes(tar2),
+ },
+ },
+ gmi: gmi(meanMgdl),
+ estimatedA1c: estimatedA1c(meanMgdl),
+ variability:
+ sd === null || cv === null
+ ? null
+ : { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD },
+ advanced: {
+ jIndex: sd === null ? null : 0.001 * (meanMgdl + sd) ** 2,
+ lbgi: lowRisk / count,
+ hbgi: highRisk / count,
+ },
+ isSpotEstimate: count / Math.max(actualSpanDays, 1) < 24,
+ };
+}
diff --git a/src/lib/doctor-report/measurement-series.ts b/src/lib/doctor-report/measurement-series.ts
new file mode 100644
index 000000000..5fdb12474
--- /dev/null
+++ b/src/lib/doctor-report/measurement-series.ts
@@ -0,0 +1,161 @@
+/**
+ * Per-type measurement series and their summary statistics.
+ *
+ * Folds the pre-aggregated dense buckets down to the same shape the raw-row
+ * path produces — one entry per local day per type, plus per-type and
+ * per-glucose-context stats — so the rest of the aggregator never has to know
+ * which path a type came down.
+ *
+ * Split out of the aggregator with the selection rework.
+ */
+import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
+import type { DoctorReportStats } from "@/lib/doctor-report-types";
+import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import type { DenseMeasurementBucket } from "./dense-buckets";
+import { glucoseClinicalFromBuckets } from "./glucose-panel";
+
+export interface DenseMeasurementSummary {
+ byType: Record>;
+ stats: Record;
+ glucoseStats: Record;
+ glucoseClinical: GlucoseClinicalMetrics;
+}
+
+interface DenseStatsAccumulator {
+ count: number;
+ sum: number;
+ min: number;
+ max: number;
+ latestAt: Date;
+ latest: number;
+}
+
+export function summariseDenseBuckets(
+ rows: readonly DenseMeasurementBucket[],
+ timezone: string,
+ sourcePriorityJson: unknown,
+ windowDays: number,
+): DenseMeasurementSummary {
+ const canonical = collapseMeasurementsToCanonical(
+ rows.map((row) => ({
+ ...row,
+ value: row.sumValue / row.count,
+ measuredAt: row.bucketStart,
+ })),
+ timezone,
+ sourcePriorityJson,
+ );
+ const byType: DenseMeasurementSummary["byType"] = {};
+ const statsAcc = new Map();
+ const glucoseStatsAcc = new Map();
+
+ const byTypeDay = new Map<
+ string,
+ {
+ type: string;
+ bucketStart: Date;
+ count: number;
+ sum: number;
+ min: number;
+ max: number;
+ latestAt: Date;
+ latest: number;
+ }
+ >();
+ for (const row of canonical) {
+ const key = `${row.type}:${row.bucketStart.toISOString()}`;
+ const current = byTypeDay.get(key);
+ if (!current) {
+ byTypeDay.set(key, {
+ type: row.type,
+ bucketStart: row.bucketStart,
+ count: row.count,
+ sum: row.sumValue,
+ min: row.minValue,
+ max: row.maxValue,
+ latestAt: row.latestAt,
+ latest: row.latestValue,
+ });
+ continue;
+ }
+ current.count += row.count;
+ current.sum += row.sumValue;
+ current.min = Math.min(current.min, row.minValue);
+ current.max = Math.max(current.max, row.maxValue);
+ if (row.latestAt > current.latestAt) {
+ current.latestAt = row.latestAt;
+ current.latest = row.latestValue;
+ }
+ }
+
+ const accumulate = (
+ target: Map,
+ key: string,
+ row: DenseStatsAccumulator,
+ ) => {
+ const current = target.get(key);
+ if (!current) {
+ target.set(key, { ...row });
+ return;
+ }
+ current.count += row.count;
+ current.sum += row.sum;
+ current.min = Math.min(current.min, row.min);
+ current.max = Math.max(current.max, row.max);
+ if (row.latestAt > current.latestAt) {
+ current.latestAt = row.latestAt;
+ current.latest = row.latest;
+ }
+ };
+
+ for (const row of byTypeDay.values()) {
+ (byType[row.type] ??= []).push({
+ value: row.sum / row.count,
+ measuredAt: row.bucketStart.toISOString(),
+ });
+ accumulate(statsAcc, row.type, row);
+ }
+
+ for (const row of canonical) {
+ if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
+ accumulate(glucoseStatsAcc, row.glucoseContext, {
+ count: row.count,
+ sum: row.sumValue,
+ min: row.minValue,
+ max: row.maxValue,
+ latestAt: row.latestAt,
+ latest: row.latestValue,
+ });
+ }
+
+ for (const entries of Object.values(byType)) {
+ entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
+ }
+ const stats: DenseMeasurementSummary["stats"] = {};
+ for (const [key, value] of statsAcc) {
+ stats[key] = {
+ count: value.count,
+ avg: value.sum / value.count,
+ min: value.min,
+ max: value.max,
+ latest: value.latest,
+ };
+ }
+ const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {};
+ for (const [key, value] of glucoseStatsAcc) {
+ glucoseStats[key] = {
+ count: value.count,
+ avg: value.sum / value.count,
+ min: value.min,
+ max: value.max,
+ latest: value.latest,
+ };
+ }
+
+ return {
+ byType,
+ stats,
+ glucoseStats,
+ glucoseClinical: glucoseClinicalFromBuckets(canonical, windowDays),
+ };
+}
diff --git a/src/lib/doctor-report/medications.ts b/src/lib/doctor-report/medications.ts
new file mode 100644
index 000000000..517a0b650
--- /dev/null
+++ b/src/lib/doctor-report/medications.ts
@@ -0,0 +1,259 @@
+/**
+ * The medication half of the doctor report: the per-dose administration
+ * ledger and the GLP-1 therapy block.
+ *
+ * Four leaves cover this domain and each one is a separate release decision:
+ * the drug list, the administration ledger, the adherence figures, and the
+ * GLP-1 block. A clinician usually wants the list and the adherence rate; the
+ * ledger is every logged dose and the GLP-1 block carries a dose history and
+ * side-effect tallies, which is a different conversation.
+ *
+ * Split out of the aggregator with the selection rework.
+ */
+import { readNote } from "@/lib/crypto/note-cipher";
+import type {
+ DoctorReportCompliance,
+ DoctorReportData,
+} from "@/lib/doctor-report-types";
+import { resolveMaxMedicationAdministrations } from "@/lib/doctor-report-helpers";
+
+/** The medication columns the ledger and the GLP-1 block read. */
+export interface MedicationRowForReport {
+ id: string;
+ name: string;
+ dose: string | null;
+ treatmentClass: string | null;
+ doseChanges: Array<{
+ effectiveFrom: Date;
+ doseValue: number;
+ doseUnit: string;
+ note: string | null;
+ noteEncrypted: Uint8Array | null;
+ }>;
+ intakeEvents: Array<{ takenAt: Date | null; injectionSite: string | null }>;
+}
+
+/** The intake columns the administration ledger reads. */
+export interface IntakeEventRowForReport {
+ medicationId: string;
+ scheduledFor: Date;
+ takenAt: Date | null;
+ skipped: boolean;
+ injectionSite: string | null;
+ medication: {
+ id: string;
+ name: string;
+ dose: string | null;
+ atcCode: string | null;
+ rxNormCode: string | null;
+ deliveryForm: string | null;
+ };
+}
+
+export interface AdministrationLedger {
+ administrations: NonNullable;
+ truncation: DoctorReportData["medicationAdministrationsTruncation"];
+}
+
+/**
+ * One entry per ACTED intake — a taken dose (completed) or an explicit skip
+ * (not-done). A scheduled-but-unconfirmed slot is not an administration event
+ * and is omitted entirely, so the export never asserts an administration that
+ * did not happen.
+ *
+ * The structured dose is resolved from the medication's dose-change history —
+ * the latest change effective at or before the administration instant — when
+ * one exists. A skip records no dose consumed.
+ */
+export function buildAdministrationLedger(
+ medications: readonly MedicationRowForReport[],
+ intakeEvents: readonly IntakeEventRowForReport[],
+): AdministrationLedger {
+ const doseChangesByMedId = new Map<
+ string,
+ Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
+ >();
+ for (const m of medications) {
+ doseChangesByMedId.set(
+ m.id,
+ m.doseChanges.map((dc) => ({
+ effectiveFrom: dc.effectiveFrom,
+ doseValue: dc.doseValue,
+ doseUnit: dc.doseUnit,
+ })),
+ );
+ }
+
+ const resolveDoseInEffect = (
+ medicationId: string,
+ at: Date,
+ ): { value: number; unit: string } | null => {
+ const changes = doseChangesByMedId.get(medicationId);
+ if (!changes || changes.length === 0) return null;
+ // Loaded ordered by `effectiveFrom asc`; take the last one whose
+ // `effectiveFrom` is at or before the administration instant.
+ let inEffect: { value: number; unit: string } | null = null;
+ for (const c of changes) {
+ if (c.effectiveFrom.getTime() <= at.getTime()) {
+ inEffect = { value: c.doseValue, unit: c.doseUnit };
+ } else {
+ break;
+ }
+ }
+ return inEffect;
+ };
+
+ const all: NonNullable = [];
+ for (const event of intakeEvents) {
+ const isTaken = event.takenAt !== null;
+ if (!isTaken && !event.skipped) continue;
+ const effectiveAt = isTaken ? (event.takenAt as Date) : event.scheduledFor;
+ all.push({
+ medicationName: event.medication.name,
+ effectiveAt: effectiveAt.toISOString(),
+ status: isTaken ? "completed" : "not-done",
+ doseText: event.medication.dose || null,
+ dose: isTaken
+ ? resolveDoseInEffect(event.medication.id, effectiveAt)
+ : null,
+ injectionSite: event.injectionSite ?? null,
+ atcCode: event.medication.atcCode ?? null,
+ rxNormCode: event.medication.rxNormCode ?? null,
+ deliveryForm: event.medication.deliveryForm ?? null,
+ });
+ }
+
+ // `intakeEvents` is ordered `scheduledFor: asc`, so the most-recent acted
+ // rows are at the tail; keep the last N and flag the trim so the narrative
+ // can disclose it. The omitted rows are the OLDEST in the window — recent
+ // adherence is preserved intact. The cap is a coarse safety ceiling against
+ // a pathological multi-year, many-medication export.
+ const cap = resolveMaxMedicationAdministrations(
+ process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
+ );
+ const total = all.length;
+ const truncated = total > cap;
+ const administrations = truncated ? all.slice(total - cap) : all;
+ return {
+ administrations,
+ truncation: truncated ? { total, included: administrations.length } : null,
+ };
+}
+
+/**
+ * Mood tags that mark a GLP-1 side effect, in both languages the tag
+ * vocabulary ships. A tag not on this list is never counted, so a free-text
+ * tag cannot leak into the block as an invented side effect.
+ */
+const SIDE_EFFECT_TAGS = new Set([
+ "nausea",
+ "constipation",
+ "diarrhea",
+ "fatigue",
+ "appetite-loss",
+ "heartburn",
+ "headache",
+ "übelkeit",
+ "verstopfung",
+ "durchfall",
+ "müdigkeit",
+ "appetitlosigkeit",
+ "sodbrennen",
+ "kopfschmerzen",
+]);
+
+function parseTags(raw: string): string[] {
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed)
+ ? parsed
+ .map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
+ .filter(Boolean)
+ : [];
+ } catch {
+ return raw
+ .split(",")
+ .map((p) => p.trim())
+ .filter(Boolean);
+ }
+}
+
+/**
+ * The GLP-1 therapy block: current dose, dose history, last injection, the
+ * weight delta over the window, and side-effect tallies.
+ *
+ * `moodTagRows` is empty unless the MOOD leaf was selected — the aggregator
+ * does not read mood entries otherwise — so the side-effect tally collapses to
+ * nothing rather than being computed from data the owner withheld.
+ */
+export function buildGlp1Block(params: {
+ medications: readonly MedicationRowForReport[];
+ compliance: Record;
+ weightSeries: ReadonlyArray<{ value: number; measuredAt: string }>;
+ moodTagRows: ReadonlyArray<{ tags: string | null }>;
+}): DoctorReportData["glp1"] {
+ const glp1Meds = params.medications.filter(
+ (m) => m.treatmentClass === "GLP1",
+ );
+ if (glp1Meds.length === 0) return null;
+
+ const weightStartKg = params.weightSeries[0]?.value ?? null;
+ const weightEndKg =
+ params.weightSeries[params.weightSeries.length - 1]?.value ?? null;
+ const weightDeltaKg =
+ weightStartKg !== null && weightEndKg !== null
+ ? Math.round((weightEndKg - weightStartKg) * 10) / 10
+ : null;
+
+ const sideEffectCounts = new Map();
+ for (const row of params.moodTagRows) {
+ for (const tag of parseTags(row.tags ?? "")) {
+ const lower = tag.toLowerCase();
+ if (!SIDE_EFFECT_TAGS.has(lower)) continue;
+ sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
+ }
+ }
+
+ return {
+ medications: glp1Meds.map((m) => {
+ const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
+ const lastIntake = m.intakeEvents[0] ?? null;
+ const comp = params.compliance[m.name] ?? {
+ taken: 0,
+ total: 0,
+ skipped: 0,
+ missed: 0,
+ };
+ return {
+ name: m.name,
+ currentDose: latest
+ ? {
+ value: latest.doseValue,
+ unit: latest.doseUnit,
+ since: latest.effectiveFrom.toISOString(),
+ }
+ : null,
+ doseHistory: m.doseChanges.map((dc) => ({
+ value: dc.doseValue,
+ unit: dc.doseUnit,
+ effectiveFrom: dc.effectiveFrom.toISOString(),
+ note: readNote(dc.noteEncrypted, dc.note),
+ })),
+ lastInjection:
+ lastIntake && lastIntake.takenAt
+ ? {
+ date: lastIntake.takenAt.toISOString(),
+ site: lastIntake.injectionSite,
+ }
+ : null,
+ compliance: { taken: comp.taken, total: comp.total },
+ };
+ }),
+ weightStartKg,
+ weightEndKg,
+ weightDeltaKg,
+ sideEffects: Array.from(sideEffectCounts.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map(([tag, count]) => ({ tag, count })),
+ };
+}
diff --git a/src/lib/doctor-report/selection-gate.ts b/src/lib/doctor-report/selection-gate.ts
new file mode 100644
index 000000000..4b72734f5
--- /dev/null
+++ b/src/lib/doctor-report/selection-gate.ts
@@ -0,0 +1,103 @@
+/**
+ * The one place a doctor-report leaf is admitted or refused.
+ *
+ * Two independent gates, and both must pass:
+ *
+ * - the per-artefact SELECTION the owner made for this report, and
+ * - the app-wide MODULE switch that decides whether the domain exists here
+ * at all.
+ *
+ * A disabled module wins over a selected leaf; a deselected leaf wins over an
+ * enabled module. Neither softens the other.
+ *
+ * The gate is fail-closed in both directions. A leaf the selection does not
+ * carry is excluded, because membership is inclusion and there is no default
+ * to consult. A measurement type the catalogue does not know is also excluded
+ * — and annotated, so a drifted enum shows up in the wide-event stream instead
+ * of riding out on a clinical document. The old filter passed such a type
+ * through, which is what made "we forgot to map it" the same as "the user
+ * asked for it".
+ */
+import type { ModuleKey } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import {
+ LEAF_MODULE,
+ isReportLeafId,
+ type ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+import type { ReportSelection } from "@/lib/report-selection/selection";
+import type { MeasurementType } from "@/generated/prisma/client";
+
+/** A resolved gate: one question, asked the same way everywhere. */
+export interface ReportGate {
+ /** Whether this artefact carries the leaf. */
+ admits(leaf: ReportLeafId): boolean;
+ /** The leaves that survived both gates, in catalogue order. */
+ readonly admitted: readonly ReportLeafId[];
+}
+
+export function resolveReportGate(
+ selection: ReportSelection,
+ moduleMap: Record,
+): ReportGate {
+ const admits = (leaf: ReportLeafId): boolean => {
+ if (!selection.has(leaf)) return false;
+ const moduleKey = LEAF_MODULE[leaf];
+ if (moduleKey && moduleMap[moduleKey] === false) return false;
+ return true;
+ };
+ return { admits, admitted: selection.leaves.filter(admits) };
+}
+
+/**
+ * The measurement types to exclude at QUERY time.
+ *
+ * Rows of a refused leaf are not fetched only to be dropped — the heaviest
+ * series (minute-grain CGM, per-sample heart rate) is exactly the one that
+ * runs to six figures over a long window, and a leaf the owner withheld should
+ * not enter this process's memory at all.
+ *
+ * `keepForDerived` names the types the aggregator reads BEFORE the output
+ * filter and therefore must not exclude even when the leaf itself is refused:
+ * the BMI figure and the GLP-1 weight delta are both computed from weight rows
+ * and are gated by their own leaves.
+ */
+export function excludedMeasurementTypes(
+ gate: ReportGate,
+ allTypes: readonly MeasurementType[],
+ keepForDerived: ReadonlySet,
+): MeasurementType[] {
+ return allTypes.filter(
+ (type) => !gate.admits(type) && !keepForDerived.has(type),
+ );
+}
+
+/**
+ * Strip every measurement type the gate refuses from a type-keyed map.
+ *
+ * This is the output half of the same decision the query gate makes, reading
+ * the same gate object, so the two can never disagree. A type that is not a
+ * catalogue leaf at all is excluded and annotated rather than passed through.
+ */
+export function filterMeasurementKeys(
+ map: Record,
+ gate: ReportGate,
+): Record {
+ const out: Record = {};
+ const unmapped = new Set();
+ for (const [type, value] of Object.entries(map)) {
+ if (!isReportLeafId(type)) {
+ // Meta, not `action` — the wide event carries one action name and it
+ // belongs to the route that is serving the request. The key is stable so
+ // a dashboard can alert on it.
+ unmapped.add(type);
+ continue;
+ }
+ if (!gate.admits(type)) continue;
+ out[type] = value;
+ }
+ if (unmapped.size > 0) {
+ annotate({ meta: { reportUnmappedLeaves: [...unmapped].sort() } });
+ }
+ return out;
+}
diff --git a/src/lib/doctor-report/type-label-keys.ts b/src/lib/doctor-report/type-label-keys.ts
deleted file mode 100644
index 9123e57b9..000000000
--- a/src/lib/doctor-report/type-label-keys.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Per-vital i18n label keys for the doctor report.
- *
- * Extracted from `doctor-report-pdf-core` so consumers that only need the
- * label map (e.g. the public clinician share view, a pure server component)
- * do not pull the jsPDF renderer into their module graph. The PDF core
- * re-exports this map so there is a single source of truth.
- *
- * Exported for coverage tests (issue #109 / phase P0) so a future enum
- * addition is caught by a unit test rather than reaching production as a raw
- * enum string in the PDF.
- */
-export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = {
- WEIGHT: "doctorReport.typeWeight",
- BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
- BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
- PULSE: "doctorReport.typePulse",
- BODY_FAT: "doctorReport.typeBodyFat",
- SLEEP_DURATION: "doctorReport.typeSleep",
- ACTIVITY_STEPS: "doctorReport.typeSteps",
- TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
- BONE_MASS: "doctorReport.typeBoneMass",
- OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
-};
diff --git a/src/lib/export/health-record-artefacts.ts b/src/lib/export/health-record-artefacts.ts
new file mode 100644
index 000000000..3fc625d32
--- /dev/null
+++ b/src/lib/export/health-record-artefacts.ts
@@ -0,0 +1,76 @@
+/**
+ * The three health-record artefacts, produced from one aggregated payload.
+ *
+ * Moved out of `POST /api/export/health-record` so the route keeps the HTTP
+ * concerns — auth, module gate, rate limit, validation, audit — and the
+ * rendering keeps its own. All three read the SAME `DoctorReportData`, which
+ * is what makes the PDF, the bundle and the zip describe an identical record
+ * by construction rather than by three renderers each remembering the scope.
+ */
+import { zipSync, strToU8 } from "fflate";
+
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
+import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
+import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
+import type { Locale } from "@/lib/i18n/config";
+import type { TimeFormatPreference } from "@/lib/format-locale";
+
+export interface ArtefactInputs {
+ data: DoctorReportData;
+ /** Decrypted KVNR, or null when the insurance leaf was not selected. */
+ insuranceNumber: string | null;
+ records: FhirRecordInputs;
+ germanAtc: boolean;
+ includeCharts: boolean;
+ locale: Locale;
+ userTz: string;
+ timeFormat: TimeFormatPreference;
+ t: (key: string, vars?: Record) => string;
+}
+
+/** The FHIR R4 document Bundle, serialised. */
+export function buildBundleJson(input: ArtefactInputs): string {
+ const bundle = buildFhirDocumentBundle(
+ input.data,
+ { insuranceNumber: input.insuranceNumber },
+ undefined,
+ { germanAtc: input.germanAtc },
+ input.records,
+ );
+ return JSON.stringify(bundle);
+}
+
+/** The rendered clinical PDF. */
+export function buildPdfBytes(input: ArtefactInputs): Uint8Array {
+ return renderDoctorReportPdfBytes(input.data, {
+ t: input.t,
+ locale: input.locale,
+ userTz: input.userTz,
+ timeFormat: input.timeFormat,
+ insuranceNumber: input.insuranceNumber,
+ includeCharts: input.includeCharts,
+ });
+}
+
+/** One zip holding the PDF, the Bundle and a README. */
+export function buildPackageZip(
+ input: ArtefactInputs,
+ pdfBytes: Uint8Array,
+): Uint8Array {
+ const bundle = buildFhirDocumentBundle(
+ input.data,
+ { insuranceNumber: input.insuranceNumber },
+ undefined,
+ { germanAtc: input.germanAtc },
+ input.records,
+ );
+ return zipSync(
+ {
+ "report.pdf": pdfBytes,
+ "bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
+ "README.txt": strToU8(input.t("doctorReport.packageReadme")),
+ },
+ { level: 6 },
+ );
+}
diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts
index 105b9be36..1ac52dd8e 100644
--- a/src/lib/mcp/prompts.ts
+++ b/src/lib/mcp/prompts.ts
@@ -23,6 +23,7 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import { summariseForVisit } from "./doctor-visit-summary";
import { coachScopeWindowSchema } from "@/lib/ai/coach/types";
@@ -305,11 +306,17 @@ export const MCP_PROMPTS: McpPromptDefinition[] = [
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
- const data = await collectDoctorReportData(ctx.userId, {
- start,
- end,
- days,
- });
+ // The assistant cannot ask a human, so this replays the owner's SAVED
+ // selection — the same object reviewed in Settings. An account that never
+ // saved one resolves to the empty selection and this surface says so,
+ // rather than inventing a scope. It used to assemble the full record at
+ // server defaults, on the one wire that egresses to a third party.
+ const selection = await resolveSavedSelection(ctx.userId);
+ const data = await collectDoctorReportData(
+ ctx.userId,
+ { start, end, days },
+ selection,
+ );
const summary = summariseForVisit(data);
annotate({
diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts
index 6fcb8b361..2d3ab6e80 100644
--- a/src/lib/mcp/resources.ts
+++ b/src/lib/mcp/resources.ts
@@ -34,6 +34,7 @@ import {
BIOMARKER_PANELS,
} from "@/lib/labs/biomarker-catalog";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CODES, type NutrientCode } from "@/lib/nutrients/catalog";
import { getNutrients, NUTRIENT_LABELS } from "@/lib/mcp/nutrients-read";
@@ -306,7 +307,17 @@ async function readDoctorVisit(
const days = WINDOW_DAYS[window] ?? 90;
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
- const data = await collectDoctorReportData(ctx.userId, { start, end, days });
+ // The assistant cannot ask a human, so this replays the owner's SAVED
+ // selection — the same object reviewed in Settings. An account that never
+ // saved one resolves to the empty selection and this surface says so,
+ // rather than inventing a scope. It used to assemble the full record at
+ // server defaults, on the one wire that egresses to a third party.
+ const selection = await resolveSavedSelection(ctx.userId);
+ const data = await collectDoctorReportData(
+ ctx.userId,
+ { start, end, days },
+ selection,
+ );
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "doctor-visit-report", days },
diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts
new file mode 100644
index 000000000..32da24148
--- /dev/null
+++ b/src/lib/measurements/type-label-keys.ts
@@ -0,0 +1,110 @@
+/**
+ * Per-measurement-type i18n label keys.
+ *
+ * Extracted from `measurement-list-meta.ts` so consumers that only need the
+ * label map — the report selection catalogue, the jsPDF renderer, the public
+ * clinician share view, any server component — do not pull the lucide icon
+ * components into their module graph. The original module re-exports this map,
+ * so there is one source of truth and no import site had to change.
+ *
+ * The `Record` annotation is the guarantee: a new
+ * enum member without a label fails `pnpm typecheck` rather than reaching a
+ * surface as a raw enum string.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+
+export const MEASUREMENT_TYPE_LABEL_KEYS: Record = {
+ WEIGHT: "measurements.typeWeight",
+ BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
+ BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
+ PULSE: "measurements.typePulse",
+ BODY_FAT: "measurements.typeBodyFat",
+ SLEEP_DURATION: "measurements.typeSleep",
+ ACTIVITY_STEPS: "measurements.typeSteps",
+ BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
+ TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
+ BONE_MASS: "measurements.typeBoneMass",
+ OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
+ // ── v1.4.23 Apple Health additions ──
+ HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
+ RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
+ ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
+ FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
+ WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
+ VO2_MAX: "measurements.typeVo2Max",
+ BODY_TEMPERATURE: "measurements.typeBodyTemperature",
+ // ── v1.4.25 W5d Withings full coverage ──
+ FAT_FREE_MASS: "measurements.typeFatFreeMass",
+ FAT_MASS: "measurements.typeFatMass",
+ MUSCLE_MASS: "measurements.typeMuscleMass",
+ SKIN_TEMPERATURE: "measurements.typeSkinTemperature",
+ PULSE_WAVE_VELOCITY: "measurements.typePulseWaveVelocity",
+ VASCULAR_AGE: "measurements.typeVascularAge",
+ VISCERAL_FAT: "measurements.typeVisceralFat",
+ // ── v1.4.25 W8d Apple Health server-prep ──
+ AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv",
+ AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone",
+ TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight",
+ // ── v1.4.30 R-F T1.4 + T1.5 ──
+ WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
+ AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
+ // ── v1.5.5 iOS-coord additions ──
+ RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
+ BODY_MASS_INDEX: "measurements.typeBodyMassIndex",
+ LEAN_BODY_MASS: "measurements.typeLeanBodyMass",
+ WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage",
+ WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry",
+ WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport",
+ // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
+ WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength",
+ WALKING_SPEED: "measurements.typeWalkingSpeed",
+ // ── v1.10.0 — additive HealthKit signals (WX-A) ──
+ CARDIO_RECOVERY: "measurements.typeCardioRecovery",
+ WRIST_TEMPERATURE: "measurements.typeWristTemperature",
+ FALL_COUNT: "measurements.typeFallCount",
+ SIX_MINUTE_WALK_DISTANCE: "measurements.typeSixMinuteWalkDistance",
+ STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed",
+ STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed",
+ BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances",
+ // ── v1.10.0 — categorical events (WX-B) ──
+ IRREGULAR_RHYTHM_NOTIFICATION: "measurements.typeIrregularRhythmNotification",
+ HIGH_HEART_RATE_EVENT: "measurements.typeHighHeartRateEvent",
+ LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent",
+ WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent",
+ BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent",
+ // ── v1.10.0 — computed scores (WX-C) ──
+ RECOVERY_SCORE: "measurements.typeRecoveryScore",
+ STRESS_SCORE: "measurements.typeStressScore",
+ STRAIN_SCORE: "measurements.typeStrainScore",
+ // ── v1.11.0 — WHOOP-native score classes ──
+ HRV_RMSSD: "measurements.typeHrvRmssd",
+ DAY_STRAIN: "measurements.typeDayStrain",
+ WORKOUT_STRAIN: "measurements.typeWorkoutStrain",
+ SLEEP_PERFORMANCE: "measurements.typeSleepPerformance",
+ SLEEP_EFFICIENCY: "measurements.typeSleepEfficiency",
+ SLEEP_CONSISTENCY: "measurements.typeSleepConsistency",
+ SLEEP_NEED: "measurements.typeSleepNeed",
+ ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj",
+ // ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
+ AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate",
+ MAX_HEART_RATE: "measurements.typeMaxHeartRate",
+ SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount",
+ // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
+ ANS_CHARGE: "measurements.typeAnsCharge",
+ CARDIO_LOAD: "measurements.typeCardioLoad",
+ // ── v1.17.1 — Oura coverage completion ──
+ SLEEP_SCORE: "measurements.typeSleepScore",
+ BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
+ // ── v1.19.0 — Oura resilience ──
+ RESILIENCE: "measurements.typeResilience",
+ // ── v1.25 — clinical-signals wave ──
+ PHQ9_SCORE: "measurements.typePhq9Score",
+ GAD7_SCORE: "measurements.typeGad7Score",
+ // ── v1.27.9 — screening scores ──
+ WHO5_SCORE: "measurements.typeWho5Score",
+ SCI_SCORE: "measurements.typeSciScore",
+ GRIP_STRENGTH: "measurements.typeGripStrength",
+ PAIN_NRS: "measurements.typePainNrs",
+ WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
+ WAIST_TO_HEIGHT: "measurements.typeWaistToHeight",
+};
diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts
index 3fd9ef2fe..7ce36fdef 100644
--- a/src/lib/openapi/routes/meta.ts
+++ b/src/lib/openapi/routes/meta.ts
@@ -101,14 +101,25 @@ const capabilitiesResponse = z
.describe(
"Maximum lifetime of a share link, in days. No never-expiring share.",
),
- resourceTypes: z
+ reportDownload: z
.array(z.string())
- .describe("FHIR resource types a share link may be scoped to serve."),
- sections: z
+ .describe(
+ "Machine formats a share link serves as a download under its token, behind the same unlock as the page.",
+ ),
+ selectionVersion: z
+ .number()
+ .int()
+ .describe("Version of the leaf-selection grammar this build speaks."),
+ groups: z
.array(z.string())
- .describe("Scopeable report sections a share link may toggle."),
+ .describe("Selection group ids, in presentation order."),
+ leaves: z
+ .array(z.string())
+ .describe(
+ "Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion.",
+ ),
})
- .describe("Clinician share-link surface descriptor (v1.11)."),
+ .describe("Clinician share-link surface descriptor."),
})
.meta({
id: "CapabilitiesResponse",
diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts
new file mode 100644
index 000000000..74a3a562e
--- /dev/null
+++ b/src/lib/report-selection/catalogue.ts
@@ -0,0 +1,380 @@
+/**
+ * The doctor-report leaf catalogue: every piece of the record a user can
+ * release or withhold, and the group it is presented under.
+ *
+ * A LEAF is the smallest releasable unit. There are exactly two kinds: every
+ * `MeasurementType` enum member, and the structured sections of the report
+ * payload that are not measurement series (identity, insurance, the glucose
+ * panel, labs, the three medication blocks, GLP-1, allergies, illness
+ * episodes, family history, mood, cycle).
+ *
+ * One catalogue drives the selection panel, the aggregator, the PDF, the
+ * clinician share view and the guard tests, so a control can never again exist
+ * without a gating path behind it, and a gate can never exist without a
+ * control.
+ *
+ * Two of the three new-metric locks live here:
+ *
+ * - `MEASUREMENT_LEAF_GROUP` is a `Record`.
+ * Adding an enum member without deciding its group fails `pnpm typecheck`,
+ * which runs in the pre-commit hook and in CI. The decision has to be made
+ * in the same commit as the enum member, and it has no effect on anyone's
+ * saved selection.
+ * - `STRUCTURED_LEAF_GROUP` does the same over the closed structured union.
+ *
+ * The third lock is set membership: a stored selection is a list of what was
+ * chosen, so a leaf that did not exist when it was saved is not in it. See
+ * `./selection.ts`.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import type { ModuleKey } from "@/lib/modules/registry";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
+
+/**
+ * The structured (non-measurement) leaves. A closed union, so
+ * `STRUCTURED_LEAF_GROUP` below is exhaustive by the compiler rather than by
+ * review.
+ */
+export type StructuredLeafId =
+ | "PATIENT_IDENTITY"
+ | "INSURANCE"
+ | "GLUCOSE_PANEL"
+ | "LAB_RESULTS"
+ | "MEDICATION_LIST"
+ | "MEDICATION_ADMINISTRATIONS"
+ | "MEDICATION_COMPLIANCE"
+ | "GLP1_THERAPY"
+ | "ALLERGIES"
+ | "ILLNESS_EPISODES"
+ | "FAMILY_HISTORY"
+ | "MOOD"
+ | "CYCLE";
+
+/** Every leaf id: the 77 measurement types plus the 13 structured sections. */
+export type ReportLeafId = MeasurementType | StructuredLeafId;
+
+/** The presentation groups, in the order the panel and the PDF render them. */
+export const REPORT_GROUP_ORDER = [
+ "identity",
+ "vitals",
+ "cardio",
+ "body",
+ "sleepRecovery",
+ "activity",
+ "mobility",
+ "environment",
+ "glucose",
+ "labs",
+ "medications",
+ "history",
+ "sensitive",
+] as const;
+
+export type ReportGroupId = (typeof REPORT_GROUP_ORDER)[number];
+
+/**
+ * The fenced tier. It renders below a separator with no group checkbox, so no
+ * single control can turn on more than one of these at a time, and it is never
+ * in the shipped template. Mental-health screening totals, mood and cycle are
+ * the user's own sensitive data; family history is third-party information
+ * about people who consented to nothing.
+ */
+export const SENSITIVE_GROUP_ID = "sensitive" satisfies ReportGroupId;
+
+/**
+ * Measurement leaf → group. Exhaustive over the enum by construction.
+ *
+ * Two placements carry a stated reason. `PAIN_NRS` sits in vitals because pain
+ * is read as a vital sign in every intake, not because it is a device reading.
+ * The four screening totals are fenced because a depression or anxiety
+ * instrument is not something to hand over as part of a bundle.
+ */
+export const MEASUREMENT_LEAF_GROUP: Record = {
+ // ── vitals ──
+ BLOOD_PRESSURE_SYS: "vitals",
+ BLOOD_PRESSURE_DIA: "vitals",
+ PULSE: "vitals",
+ OXYGEN_SATURATION: "vitals",
+ RESPIRATORY_RATE: "vitals",
+ BODY_TEMPERATURE: "vitals",
+ PAIN_NRS: "vitals",
+
+ // ── cardio ──
+ RESTING_HEART_RATE: "cardio",
+ HEART_RATE_VARIABILITY: "cardio",
+ HRV_RMSSD: "cardio",
+ VO2_MAX: "cardio",
+ WALKING_HEART_RATE_AVERAGE: "cardio",
+ AVERAGE_HEART_RATE: "cardio",
+ MAX_HEART_RATE: "cardio",
+ CARDIO_RECOVERY: "cardio",
+ CARDIO_LOAD: "cardio",
+ PULSE_WAVE_VELOCITY: "cardio",
+ VASCULAR_AGE: "cardio",
+ IRREGULAR_RHYTHM_NOTIFICATION: "cardio",
+ HIGH_HEART_RATE_EVENT: "cardio",
+ LOW_HEART_RATE_EVENT: "cardio",
+
+ // ── body ──
+ WEIGHT: "body",
+ BODY_MASS_INDEX: "body",
+ BODY_FAT: "body",
+ FAT_MASS: "body",
+ FAT_FREE_MASS: "body",
+ MUSCLE_MASS: "body",
+ LEAN_BODY_MASS: "body",
+ TOTAL_BODY_WATER: "body",
+ BONE_MASS: "body",
+ VISCERAL_FAT: "body",
+ WAIST_CIRCUMFERENCE: "body",
+ WAIST_TO_HEIGHT: "body",
+
+ // ── sleep & recovery ──
+ SLEEP_DURATION: "sleepRecovery",
+ SLEEP_SCORE: "sleepRecovery",
+ SLEEP_PERFORMANCE: "sleepRecovery",
+ SLEEP_EFFICIENCY: "sleepRecovery",
+ SLEEP_CONSISTENCY: "sleepRecovery",
+ SLEEP_NEED: "sleepRecovery",
+ SLEEP_DISTURBANCE_COUNT: "sleepRecovery",
+ BREATHING_DISTURBANCES: "sleepRecovery",
+ BREATHING_DISTURBANCE_EVENT: "sleepRecovery",
+ RECOVERY_SCORE: "sleepRecovery",
+ STRESS_SCORE: "sleepRecovery",
+ RESILIENCE: "sleepRecovery",
+ ANS_CHARGE: "sleepRecovery",
+ SKIN_TEMPERATURE: "sleepRecovery",
+ WRIST_TEMPERATURE: "sleepRecovery",
+ BODY_TEMPERATURE_DEVIATION: "sleepRecovery",
+
+ // ── activity ──
+ ACTIVITY_STEPS: "activity",
+ WALKING_RUNNING_DISTANCE: "activity",
+ FLIGHTS_CLIMBED: "activity",
+ ACTIVE_ENERGY_BURNED: "activity",
+ ENERGY_EXPENDITURE_KJ: "activity",
+ STRAIN_SCORE: "activity",
+ DAY_STRAIN: "activity",
+ WORKOUT_STRAIN: "activity",
+
+ // ── mobility & gait ──
+ WALKING_SPEED: "mobility",
+ WALKING_ASYMMETRY: "mobility",
+ WALKING_DOUBLE_SUPPORT: "mobility",
+ WALKING_STEP_LENGTH: "mobility",
+ WALKING_STEADINESS: "mobility",
+ WALKING_STEADINESS_EVENT: "mobility",
+ STAIR_ASCENT_SPEED: "mobility",
+ STAIR_DESCENT_SPEED: "mobility",
+ SIX_MINUTE_WALK_DISTANCE: "mobility",
+ FALL_COUNT: "mobility",
+ GRIP_STRENGTH: "mobility",
+
+ // ── environment ──
+ AUDIO_EXPOSURE_ENV: "environment",
+ AUDIO_EXPOSURE_HEADPHONE: "environment",
+ AUDIO_EXPOSURE_EVENT: "environment",
+ TIME_IN_DAYLIGHT: "environment",
+
+ // ── glucose ──
+ BLOOD_GLUCOSE: "glucose",
+
+ // ── fenced ──
+ PHQ9_SCORE: "sensitive",
+ GAD7_SCORE: "sensitive",
+ WHO5_SCORE: "sensitive",
+ SCI_SCORE: "sensitive",
+};
+
+/** Structured leaf → group. Exhaustive over the closed union. */
+export const STRUCTURED_LEAF_GROUP: Record = {
+ PATIENT_IDENTITY: "identity",
+ INSURANCE: "identity",
+ GLUCOSE_PANEL: "glucose",
+ LAB_RESULTS: "labs",
+ MEDICATION_LIST: "medications",
+ MEDICATION_ADMINISTRATIONS: "medications",
+ MEDICATION_COMPLIANCE: "medications",
+ GLP1_THERAPY: "medications",
+ ALLERGIES: "history",
+ ILLNESS_EPISODES: "history",
+ FAMILY_HISTORY: "sensitive",
+ MOOD: "sensitive",
+ CYCLE: "sensitive",
+};
+
+/** i18n label keys for the 13 structured leaves. */
+export const STRUCTURED_LEAF_LABEL_KEYS: Record = {
+ PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
+ INSURANCE: "reportSelection.leafInsurance",
+ GLUCOSE_PANEL: "reportSelection.leafGlucosePanel",
+ LAB_RESULTS: "reportSelection.leafLabResults",
+ MEDICATION_LIST: "reportSelection.leafMedicationList",
+ MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
+ MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
+ GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
+ ALLERGIES: "reportSelection.leafAllergies",
+ ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
+ FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
+ MOOD: "reportSelection.leafMood",
+ CYCLE: "reportSelection.leafCycle",
+};
+
+/** i18n label keys for the groups, including the fenced tier's heading. */
+export const REPORT_GROUP_LABEL_KEYS: Record = {
+ identity: "reportSelection.groupIdentity",
+ vitals: "reportSelection.groupVitals",
+ cardio: "reportSelection.groupCardio",
+ body: "reportSelection.groupBody",
+ sleepRecovery: "reportSelection.groupSleepRecovery",
+ activity: "reportSelection.groupActivity",
+ mobility: "reportSelection.groupMobility",
+ environment: "reportSelection.groupEnvironment",
+ glucose: "reportSelection.groupGlucose",
+ labs: "reportSelection.groupLabs",
+ medications: "reportSelection.groupMedications",
+ history: "reportSelection.groupHistory",
+ sensitive: "reportSelection.groupSensitive",
+};
+
+/**
+ * v1.18.0 — leaf → owning module. The module map still ANDs over the
+ * selection: a disabled module wins over a selected leaf, and a deselected
+ * leaf wins over an enabled module. Neither softens the other.
+ *
+ * Core clinical leaves (blood pressure, weight, pulse, body composition,
+ * identity) carry no module and are never gated here.
+ */
+export const LEAF_MODULE: Partial> = {
+ // Activity / movement series and the training-load scores.
+ ACTIVITY_STEPS: "workouts",
+ ACTIVE_ENERGY_BURNED: "workouts",
+ ENERGY_EXPENDITURE_KJ: "workouts",
+ FLIGHTS_CLIMBED: "workouts",
+ WALKING_RUNNING_DISTANCE: "workouts",
+ WALKING_SPEED: "workouts",
+ WALKING_ASYMMETRY: "workouts",
+ WALKING_STEP_LENGTH: "workouts",
+ WALKING_DOUBLE_SUPPORT: "workouts",
+ WALKING_STEADINESS: "workouts",
+ WALKING_STEADINESS_EVENT: "workouts",
+ SIX_MINUTE_WALK_DISTANCE: "workouts",
+ STAIR_ASCENT_SPEED: "workouts",
+ STAIR_DESCENT_SPEED: "workouts",
+ STRAIN_SCORE: "workouts",
+ DAY_STRAIN: "workouts",
+ WORKOUT_STRAIN: "workouts",
+ // Readiness signals.
+ RECOVERY_SCORE: "recovery",
+ STRESS_SCORE: "recovery",
+ RESILIENCE: "recovery",
+ ANS_CHARGE: "recovery",
+ // Sleep.
+ SLEEP_DURATION: "sleep",
+ SLEEP_SCORE: "sleep",
+ SLEEP_PERFORMANCE: "sleep",
+ SLEEP_EFFICIENCY: "sleep",
+ SLEEP_CONSISTENCY: "sleep",
+ SLEEP_NEED: "sleep",
+ SLEEP_DISTURBANCE_COUNT: "sleep",
+ BREATHING_DISTURBANCES: "sleep",
+ BREATHING_DISTURBANCE_EVENT: "sleep",
+ // Environment exposure.
+ AUDIO_EXPOSURE_ENV: "environment",
+ AUDIO_EXPOSURE_HEADPHONE: "environment",
+ AUDIO_EXPOSURE_EVENT: "environment",
+ TIME_IN_DAYLIGHT: "environment",
+ // Glucose: the raw series and the derived panel.
+ BLOOD_GLUCOSE: "glucose",
+ GLUCOSE_PANEL: "glucose",
+ // The screening totals ride the mental-health module as well as being
+ // fenced in the panel. Item-level answers are never a Measurement, so only
+ // the total — the intended clinical artefact — can reach this path at all.
+ PHQ9_SCORE: "mentalHealth",
+ GAD7_SCORE: "mentalHealth",
+ WHO5_SCORE: "mentalHealth",
+ SCI_SCORE: "mentalHealth",
+ // Structured sections with an owning module.
+ LAB_RESULTS: "labs",
+ MOOD: "mood",
+ CYCLE: "cycle",
+ ILLNESS_EPISODES: "illness",
+ MEDICATION_LIST: "medications",
+ MEDICATION_ADMINISTRATIONS: "medications",
+ MEDICATION_COMPLIANCE: "medications",
+ GLP1_THERAPY: "medications",
+};
+
+/** Every measurement leaf id, in enum order. */
+export const MEASUREMENT_LEAF_IDS = Object.keys(
+ MEASUREMENT_LEAF_GROUP,
+) as MeasurementType[];
+
+/** Every structured leaf id. */
+export const STRUCTURED_LEAF_IDS = Object.keys(
+ STRUCTURED_LEAF_GROUP,
+) as StructuredLeafId[];
+
+/**
+ * Every leaf id, grouped and ordered the way the panel renders them: group
+ * order first, structured leaves before measurement leaves inside a group.
+ * The PDF and the clinician view read the same order, so the panel is a table
+ * of contents for the artefact.
+ */
+export const REPORT_GROUPS: ReadonlyArray<{
+ id: ReportGroupId;
+ labelKey: string;
+ leaves: readonly ReportLeafId[];
+}> = REPORT_GROUP_ORDER.map((id) => ({
+ id,
+ labelKey: REPORT_GROUP_LABEL_KEYS[id],
+ leaves: [
+ ...STRUCTURED_LEAF_IDS.filter((leaf) => STRUCTURED_LEAF_GROUP[leaf] === id),
+ ...MEASUREMENT_LEAF_IDS.filter(
+ (leaf) => MEASUREMENT_LEAF_GROUP[leaf] === id,
+ ),
+ ],
+}));
+
+/** The full ordered leaf list — the stable order a stored selection uses. */
+export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap(
+ (group) => group.leaves,
+);
+
+const ALL_LEAF_SET = new Set(ALL_LEAF_IDS);
+
+/** Whether a raw string is a leaf id this build knows. */
+export function isReportLeafId(value: unknown): value is ReportLeafId {
+ return typeof value === "string" && ALL_LEAF_SET.has(value);
+}
+
+const STRUCTURED_LEAF_SET = new Set(STRUCTURED_LEAF_IDS);
+
+/** Whether a leaf id is one of the structured sections. */
+export function isStructuredLeafId(
+ leaf: ReportLeafId,
+): leaf is StructuredLeafId {
+ return STRUCTURED_LEAF_SET.has(leaf);
+}
+
+/** The fenced leaves, as a set, for the template-purity guard and the panel. */
+export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
+ (leaf) =>
+ isStructuredLeafId(leaf)
+ ? STRUCTURED_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID
+ : MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
+);
+
+/** The i18n label key for any leaf. */
+export function leafLabelKey(leaf: ReportLeafId): string {
+ return isStructuredLeafId(leaf)
+ ? STRUCTURED_LEAF_LABEL_KEYS[leaf]
+ : MEASUREMENT_TYPE_LABEL_KEYS[leaf];
+}
+
+/** The group a leaf belongs to. */
+export function leafGroup(leaf: ReportLeafId): ReportGroupId {
+ return isStructuredLeafId(leaf)
+ ? STRUCTURED_LEAF_GROUP[leaf]
+ : MEASUREMENT_LEAF_GROUP[leaf];
+}
diff --git a/src/lib/report-selection/saved-profile.ts b/src/lib/report-selection/saved-profile.ts
new file mode 100644
index 000000000..8dc7f6fd6
--- /dev/null
+++ b/src/lib/report-selection/saved-profile.ts
@@ -0,0 +1,72 @@
+/**
+ * The owner's saved report selection — "my selection".
+ *
+ * Persisted on `User.reportSelectionJson` and written after a successful
+ * generation, the same way the practice name is. Two kinds of caller read it:
+ *
+ * - the export panel and the share-link create form, so the panel opens
+ * already carrying the last scope the owner chose, and
+ * - the surfaces that cannot ask a human — the FHIR REST face and the MCP
+ * doctor-visit resource and prompt.
+ *
+ * The second group is the reason this exists. Those three call sites used to
+ * assemble the full record at server defaults. Now they replay a real act by
+ * the owner: the same object reviewed in Settings, no more and no less. An
+ * account that has never saved one resolves to the EMPTY selection, so an
+ * assistant asking for a record the owner never scoped gets nothing — not a
+ * template, not a guess.
+ */
+import { z } from "zod/v4";
+
+import { prisma } from "@/lib/db";
+import {
+ EMPTY_REPORT_SELECTION,
+ reportSelectionSchema,
+ selectionFromStoredBlob,
+ type ReportSelection,
+} from "./selection";
+
+/**
+ * The stored profile: the leaf list plus the render choices the panel
+ * restores. `format` / `rangeDays` / `includeCharts` are presentation, not
+ * scope — no gate reads them — so they ride alongside rather than inside the
+ * selection.
+ */
+export const savedReportProfileSchema = reportSelectionSchema.extend({
+ format: z.enum(["pdf", "fhir", "package"]),
+ rangeDays: z.number().int().min(1).max(365),
+ includeCharts: z.boolean(),
+});
+
+export type SavedReportProfile = z.infer;
+
+/** What the panel seeds from when the account has never saved a profile. */
+export const SAVED_PROFILE_FALLBACK = {
+ format: "pdf",
+ rangeDays: 90,
+ includeCharts: true,
+} as const;
+
+/**
+ * Resolve the owner's saved selection.
+ *
+ * Anything that is not a well-formed v2 blob — null, a drifted shape, a
+ * hand-edited row — resolves to the empty selection. A shape the server cannot
+ * read is not consent to anything.
+ */
+export async function resolveSavedSelection(
+ userId: string,
+): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { reportSelectionJson: true },
+ });
+ if (!row) return EMPTY_REPORT_SELECTION;
+ return selectionFromStoredBlob(row.reportSelectionJson);
+}
+
+/** Parse a stored profile blob for the panel, or null when there is none. */
+export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
+ const parsed = savedReportProfileSchema.safeParse(raw);
+ return parsed.success ? parsed.data : null;
+}
diff --git a/src/lib/report-selection/selection.ts b/src/lib/report-selection/selection.ts
new file mode 100644
index 000000000..50adaaf96
--- /dev/null
+++ b/src/lib/report-selection/selection.ts
@@ -0,0 +1,131 @@
+/**
+ * The resolved report selection: an inclusion list of leaf ids, and the only
+ * three ways to mint one.
+ *
+ * Membership is inclusion. Absence is exclusion. There is no defaults constant
+ * anywhere in this module, because a default that means "yes" is the defect
+ * this shape exists to remove: the flat preferences object it replaces
+ * resolved an absent key to "included", so a caller who sent nothing received
+ * the whole record.
+ *
+ * The brand means a call site cannot hand `collectDoctorReportData` an object
+ * literal. It has to go through one of the three minting functions below, and
+ * those are the only place the version policy and the unknown-id policy live.
+ */
+import { z } from "zod/v4";
+
+import { isReportLeafId, type ReportLeafId, ALL_LEAF_IDS } from "./catalogue";
+
+declare const brand: unique symbol;
+
+/** The stored/wire version. Bumped only when the leaf grammar itself changes. */
+export const REPORT_SELECTION_VERSION = 2;
+
+export interface ReportSelection {
+ readonly [brand]: "report-selection";
+ readonly version: typeof REPORT_SELECTION_VERSION;
+ /** Whether this selection admits a leaf. The one question every gate asks. */
+ has(leaf: ReportLeafId): boolean;
+ /** The chosen leaves in catalogue order — for the audit row and scope line. */
+ readonly leaves: readonly ReportLeafId[];
+}
+
+/** The wire shape of a selection, as sent and as stored. */
+export const reportSelectionSchema = z
+ .object({
+ v: z.literal(REPORT_SELECTION_VERSION),
+ leaves: z.array(z.string().min(1).max(64)).max(ALL_LEAF_IDS.length),
+ })
+ .strict();
+
+export type ReportSelectionInput = z.infer;
+
+const LEAF_ORDER = new Map(
+ ALL_LEAF_IDS.map((leaf, index) => [leaf, index]),
+);
+
+function mint(leaves: Iterable): ReportSelection {
+ const set = new Set(leaves);
+ const ordered = ALL_LEAF_IDS.filter((leaf) => set.has(leaf));
+ return {
+ version: REPORT_SELECTION_VERSION,
+ has: (leaf: ReportLeafId) => set.has(leaf),
+ leaves: ordered,
+ } as unknown as ReportSelection;
+}
+
+/**
+ * Mint from an explicit leaf list — the shipped template, the panel's own
+ * state, and tests. Callers pass ids the compiler already checked.
+ */
+export function selectionFromLeaves(
+ leaves: Iterable,
+): ReportSelection {
+ return mint(leaves);
+}
+
+/** The empty selection: nothing chosen, so nothing is served. */
+export const EMPTY_REPORT_SELECTION: ReportSelection = mint([]);
+
+/** What went wrong parsing a request selection, for the 422 that names it. */
+export interface SelectionRequestError {
+ /** Leaf ids the caller sent that this build does not know. */
+ unknownLeaves: string[];
+}
+
+/**
+ * Mint from the Zod-parsed `selection` field of an API body.
+ *
+ * Unknown ids are REJECTED rather than dropped: a client sending an id the
+ * server does not know is a contract mismatch the client has to see, and
+ * dropping it would silently narrow a scope the caller did express.
+ */
+export function selectionFromRequest(
+ input: ReportSelectionInput,
+):
+ | { ok: true; selection: ReportSelection }
+ | { ok: false; error: SelectionRequestError } {
+ const unknownLeaves = input.leaves.filter((leaf) => !isReportLeafId(leaf));
+ if (unknownLeaves.length > 0) {
+ return { ok: false, error: { unknownLeaves } };
+ }
+ return { ok: true, selection: mint(input.leaves as ReportLeafId[]) };
+}
+
+/**
+ * Mint from a stored blob — a share link's frozen `sectionsJson` or the
+ * owner's saved profile.
+ *
+ * Unknown ids are DROPPED and never added to: a leaf removed in a later
+ * version simply stops being part of that selection. Anything that is not a
+ * well-formed v2 blob resolves to the EMPTY selection, not to a default set —
+ * a shape the server cannot read is not consent to anything. That is what
+ * makes the legacy share links in migration 0273 revocable rather than
+ * silently re-scoped.
+ */
+export function selectionFromStoredBlob(raw: unknown): ReportSelection {
+ const parsed = reportSelectionSchema.safeParse(raw);
+ if (!parsed.success) return EMPTY_REPORT_SELECTION;
+ return mint(parsed.data.leaves.filter(isReportLeafId));
+}
+
+/** Serialise a selection to the stored / wire shape. */
+export function selectionToBlob(
+ selection: ReportSelection,
+): ReportSelectionInput {
+ return { v: REPORT_SELECTION_VERSION, leaves: [...selection.leaves] };
+}
+
+/** Whether this selection admits nothing at all. */
+export function isEmptySelection(selection: ReportSelection): boolean {
+ return selection.leaves.length === 0;
+}
+
+/** Stable ordering helper, exported for the panel's own state folding. */
+export function orderLeaves(
+ leaves: Iterable,
+): readonly ReportLeafId[] {
+ return [...new Set(leaves)].sort(
+ (a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0),
+ );
+}
diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts
new file mode 100644
index 000000000..b83811681
--- /dev/null
+++ b/src/lib/report-selection/template.ts
@@ -0,0 +1,46 @@
+/**
+ * The one shipped template: what a general doctor's visit usually needs.
+ *
+ * It is applied on the FIRST run only, with the panel open, so the user
+ * presses Generate having seen twelve named groups and an empty fenced tier.
+ * That is consent to a named bundle, not a server-chosen default — nothing
+ * here is applied to a stored selection, to a share link, or to any surface
+ * that cannot ask a human.
+ *
+ * The template intersects the fenced tier at zero leaves, and
+ * `report-template-purity.test.ts` fails the build if that stops being true.
+ */
+import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
+
+/**
+ * Identity and insurer (the cover page a practice files the document under),
+ * the three classic vitals, weight and BMI, the glucose series and its panel,
+ * labs, the drug list and adherence, allergies and past illness episodes.
+ *
+ * Deliberately not in it: everything a wearable produces, gait and mobility,
+ * environment exposure, the per-dose administration ledger (a clinician asks
+ * for adherence, not for every logged dose), the GLP-1 block, and the whole
+ * fenced tier.
+ */
+export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
+ "PATIENT_IDENTITY",
+ "INSURANCE",
+ "BLOOD_PRESSURE_SYS",
+ "BLOOD_PRESSURE_DIA",
+ "PULSE",
+ "WEIGHT",
+ "BODY_MASS_INDEX",
+ "BLOOD_GLUCOSE",
+ "GLUCOSE_PANEL",
+ "LAB_RESULTS",
+ "MEDICATION_LIST",
+ "MEDICATION_COMPLIANCE",
+ "ALLERGIES",
+ "ILLNESS_EPISODES",
+];
+
+/** The template's i18n label — it is named in the panel, never anonymous. */
+export const STANDARD_TEMPLATE_LABEL_KEY = "reportSelection.templateStandard";
+
+/** Exported so the purity guard reads the same set the panel does. */
+export const SENSITIVE_LEAVES = SENSITIVE_LEAF_IDS;
diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts
index 6e0428acf..cbe22ff72 100644
--- a/src/lib/validations/clinician-share-link.ts
+++ b/src/lib/validations/clinician-share-link.ts
@@ -8,18 +8,28 @@
* 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.
+ * The `resourceTypes` / `allowFhirApi` request keys are gone. They configured
+ * a scoped FHIR face for a share link that was never built, 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. The link's record IS
+ * reachable in machine form now — as a download at `/c/{token}/fhir`, behind
+ * the same unlock the page uses, serving exactly the frozen selection.
*
* Strict: `.strict()` rejects unknown keys; there is intentionally no `userId`
* field (the owner is always narrowed from `requireAuth()`).
*/
import { z } from "zod/v4";
-import { exportSectionsSchema } from "@/lib/validations/health-record-export";
+import { reportSelectionSchema } from "@/lib/report-selection/selection";
+
+/**
+ * The one leaf a share link may never carry. The share view has never
+ * decrypted the insurance number, so refusing it here makes that structural
+ * instead of incidental: the create route 422s naming the leaf, and no path
+ * exists by which a link could later be widened to include it.
+ */
+export const SHARE_LINK_FORBIDDEN_LEAVES = ["INSURANCE"] as const;
/** Maximum lifetime of a share link, in days. No never-expiring share. */
export const SHARE_LINK_MAX_DAYS = 90;
@@ -48,7 +58,7 @@ export const createShareLinkSchema = z
label: z.string().trim().min(1).max(120),
rangeStart: z.iso.datetime({ offset: true }),
rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(),
- sections: exportSectionsSchema.optional(),
+ selection: reportSelectionSchema.optional(),
documentIds: z
.array(z.string().trim().min(1).max(64))
.max(SHARE_LINK_MAX_DOCUMENTS)
@@ -58,7 +68,7 @@ export const createShareLinkSchema = z
* 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
+ * `documentId` (enforced below). Any `selection` sent alongside is ignored
* server-side so a document link can never widen into a record.
*/
documentOnly: z.boolean().optional(),
diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts
deleted file mode 100644
index 48d8c5859..000000000
--- a/src/lib/validations/doctor-report-prefs.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * v1.4.25 W6c — per-user Doctor-Report section toggles.
- *
- * Persisted as a Json blob on `User.doctorReportPrefsJson`. Null = the
- * documented defaults (every section ON except mood). The dialog reads
- * the row, lets the user flip checkboxes, and writes the chosen shape
- * back. The PDF generator + the report aggregator both consult the
- * shape: aggregator drops mood data entirely when `mood = false` so the
- * data never leaves the DB row (privacy-by-default), and the generator
- * skips each section whose flag is false at render time.
- *
- * The shape is intentionally additive — every new data type added to the
- * report grows the schema with a new optional flag and a new entry in
- * `DEFAULT_DOCTOR_REPORT_PREFS`. Forward-compat: an unknown / drifted
- * shape falls back to defaults rather than throwing.
- */
-import { z } from "zod/v4";
-
-/**
- * Section toggle schema. Every key is optional so a partial update from
- * the dialog (e.g., "the user only flipped mood") doesn't have to
- * re-state every other flag. The route layer fills missing keys from the
- * defaults before persisting so the column shape stays stable.
- */
-export const doctorReportPrefsSchema = z
- .object({
- bp: z.boolean(),
- weight: z.boolean(),
- pulse: z.boolean(),
- bmi: z.boolean(),
- mood: z.boolean(),
- medicationList: z.boolean(),
- compliance: z.boolean(),
- sleep: z.boolean(),
- glucose: z.boolean(),
- cycle: z.boolean(),
- 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();
-
-export type DoctorReportPrefsInput = z.infer;
-
-/**
- * Fully-resolved section toggles. Every key required so the consumers
- * (PDF renderer + aggregator) don't have to thread an "is this key
- * present?" check through their render paths.
- */
-export interface DoctorReportPrefs {
- bp: boolean;
- weight: boolean;
- 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;
- cycle: boolean;
- 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;
-}
-
-/**
- * Defaults applied when the user has never opened the dialog. Every
- * section is ON by default EXCEPT mood, which is opt-in per the maintainer's
- * privacy directive (2026-05-14): mental-health data should never appear
- * in a clinical PDF the user didn't explicitly check.
- */
-export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
- bp: true,
- weight: true,
- 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,
- // like BP / weight / labs. The per-report toggle lets a diabetic user
- // withhold glucose from a SPECIFIC shared report without disabling the
- // glucose module app-wide (default-on preserves the pre-toggle behaviour,
- // where glucose always rendered).
- glucose: true,
- // Cycle data is opt-in: a user sharing a BP report with a cardiologist
- // should not auto-leak reproductive data. Same privacy stance as mood.
- cycle: false,
- // Lab results the user recorded for exactly this purpose — sharing
- // bloodwork with a clinician. ON by default, like BP / weight.
- labs: true,
- // Structured allergy / intolerance records — the section every clinical
- // intake asks for first. Reference data recorded to share, ON by default
- // like labs.
- 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,
-};
-
-/**
- * The "no section" shape — every toggle OFF. A share link that carries ONLY
- * documents (the "share this document, not the whole record" flow) persists
- * this so the clinician view resolves to an empty report scope and NO health
- * data is ever aggregated for it. Distinct from the null / `{}` case, which
- * resolves to {@link DEFAULT_DOCTOR_REPORT_PREFS} (a full record share).
- */
-export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
- bp: false,
- weight: false,
- pulse: false,
- bmi: false,
- mood: false,
- medicationList: false,
- compliance: false,
- sleep: false,
- glucose: false,
- cycle: false,
- labs: false,
- allergies: false,
- familyHistory: false,
- oxygenSaturation: false,
- bodyFat: false,
- bodyComposition: false,
- restingHeartRate: false,
- hrv: false,
- vo2max: false,
- steps: false,
- distance: false,
- mentalHealthScreeners: false,
-};
-
-/**
- * Whether any report section is enabled. A resolved prefs object with every
- * flag OFF means "no health report" — the load-bearing signal a documents-only
- * share reads to serve zero metrics. The clinician-view data loader gates the
- * whole doctor-report aggregation on this: false ⇒ never touch the DB for
- * health data.
- */
-export function hasAnyReportSection(prefs: DoctorReportPrefs): boolean {
- return Object.values(prefs).some(Boolean);
-}
-
-/**
- * Parse a row's `doctorReportPrefsJson` Json blob into a typed
- * `DoctorReportPrefs`, falling back to the documented defaults when the
- * row is null OR the persisted shape has drifted (a forward-compat field
- * rename, an admin-side hand-edit, etc.). Missing keys are filled from
- * the defaults so callers always get a fully-resolved object.
- */
-export function parseDoctorReportPrefs(raw: unknown): DoctorReportPrefs {
- if (raw == null) return { ...DEFAULT_DOCTOR_REPORT_PREFS };
- const parsed = doctorReportPrefsSchema.safeParse(raw);
- if (!parsed.success) return { ...DEFAULT_DOCTOR_REPORT_PREFS };
- return {
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...parsed.data,
- };
-}
-
-/**
- * Resolve a partial input (typically from a PUT body) into the full
- * canonical shape, layering the supplied keys over the current
- * persisted row (or defaults when null). Keeps the route layer free of
- * merge plumbing.
- */
-export function resolveDoctorReportPrefs(
- current: unknown,
- incoming: DoctorReportPrefsInput,
-): DoctorReportPrefs {
- const base = parseDoctorReportPrefs(current);
- return { ...base, ...incoming };
-}
diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts
index 310cd26c2..3b0ffea17 100644
--- a/src/lib/validations/health-record-export.ts
+++ b/src/lib/validations/health-record-export.ts
@@ -1,155 +1,28 @@
/**
- * v1.7.0 — health-record / doctor-handover export selection.
+ * The health-record / doctor-handover export selection.
*
- * Single strict Zod schema driving `POST /api/export/health-record`. The
- * flagship export route fails loudly on bad input (unlike the legacy
- * doctor-report route which tolerates drift) — `.strict()` rejects unknown
- * keys, and there is intentionally NO `userId` field (the user is always
- * narrowed from `requireAuth()`, never accepted from the body).
+ * One strict Zod schema driving `POST /api/export/health-record`. `.strict()`
+ * rejects unknown keys, and there is intentionally NO `userId` field — the
+ * user is always narrowed from `requireAuth()`, never accepted from the body.
*
- * The grouped `sections` shape is additive over the flat
- * `DoctorReportPrefs` used by the aggregator + PDF renderer. The
- * `toDoctorReportPrefs()` helper folds the grouped toggles down to the
- * flat shape the aggregator consumes, so PDF and FHIR describe identical
- * numbers (the source-of-truth property the two PDF endpoints share).
+ * `selection` is REQUIRED and is the leaf inclusion list. The grouped
+ * `sections` shape it replaces resolved an absent key to "included", so a
+ * caller who sent nothing received roughly fifty metrics plus the medication
+ * list, labs, allergies and family history — a scope nobody chose. Sending
+ * `sections` now 422s with the key named rather than being folded into a
+ * default, because silently accepting the old shape would mean silently
+ * choosing a scope the caller did not express.
+ *
+ * The AI summary is gone with it. Its text was pulled from the cached
+ * briefing with no relation to the selection at all, so its scope could not be
+ * proven — and prose whose scope cannot be proven cannot ride a document whose
+ * whole point is a proven scope.
*/
import { z } from "zod/v4";
+
import { locales } from "@/lib/i18n/config";
-import {
- DEFAULT_DOCTOR_REPORT_PREFS,
- parseDoctorReportPrefs,
- type DoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
+import { reportSelectionSchema } from "@/lib/report-selection/selection";
-/** Grouped vital toggles. */
-const vitalsGroupSchema = z
- .object({
- weight: z.boolean(),
- bp: z.boolean(),
- pulse: z.boolean(),
- oxygenSaturation: z.boolean(),
- bodyFat: z.boolean(),
- bodyComposition: z.boolean(),
- })
- .partial();
-
-const cardioFitnessGroupSchema = z
- .object({
- restingHeartRate: z.boolean(),
- hrv: z.boolean(),
- vo2max: z.boolean(),
- })
- .partial();
-
-const activityGroupSchema = z
- .object({
- steps: z.boolean(),
- distance: z.boolean(),
- 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(),
- })
- .partial();
-
-/**
- * Grouped section toggles. Every group + key is optional so a partial
- * payload (the user flipped one checkbox) doesn't have to re-state the
- * whole tree. Missing keys resolve to the documented defaults.
- */
-export const exportSectionsSchema = z
- .object({
- vitals: vitalsGroupSchema,
- cardioFitness: cardioFitnessGroupSchema,
- activity: activityGroupSchema,
- glucose: z.boolean(),
- medications: medicationsGroupSchema,
- mood: z.boolean(),
- bmi: z.boolean(),
- // Cycle / reproductive health — opt-in (privacy default OFF, like mood).
- cycle: z.boolean(),
- // Structured lab results — ON by default (recorded to share with a
- // clinician, same stance as BP / weight).
- labs: z.boolean(),
- // Structured allergy / intolerance records — ON by default (the
- // section every clinical intake asks for first; same stance as labs).
- allergies: z.boolean(),
- // Structured family history — ON by default, same stance as allergies.
- familyHistory: z.boolean(),
- // 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();
-
-export type ExportSections = z.infer;
-
-/**
- * The top-level keys that exist ONLY in the grouped {@link exportSectionsSchema}
- * (never in the flat `doctorReportPrefsSchema`). Their presence is the reliable
- * signal that a persisted `sectionsJson` blob is the grouped shape rather than
- * the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top
- * level, the grouped shape nests them under `vitals`.
- *
- * `glucose` is deliberately NOT a discriminator: it is a top-level boolean in
- * BOTH shapes (the flat `DoctorReportPrefs` gained its own `glucose` section
- * toggle), so its presence no longer distinguishes grouped from flat. A real
- * grouped export always carries one of the nested groups below, so detection
- * stays reliable without it — and a bare `{ glucose: false }` blob correctly
- * resolves through the flat parser.
- */
-const GROUPED_SECTION_KEYS = [
- "vitals",
- "cardioFitness",
- "activity",
- "medications",
-] as const;
-
-/**
- * v1.28.17 — resolve a STORED `sectionsJson` blob (from a clinician share link
- * or any consumer that persists the export selection) to the flat
- * {@link DoctorReportPrefs} the aggregator + PDF renderer consume.
- *
- * The share-link create schema accepts the GROUPED {@link exportSectionsSchema}
- * and persists it raw, but the report aggregator speaks the FLAT shape. Reading
- * a grouped blob through the flat parser silently DROPS every grouped toggle
- * (`vitals.bp`, `activity.sleep`, `medications.compliance`) and falls back to
- * defaults-ON — so a section the owner switched OFF would still be served. This
- * resolver closes that gap: a grouped blob is folded through
- * {@link toDoctorReportPrefs}; a flat / empty / `{}` / null blob keeps the exact
- * legacy semantics via {@link parseDoctorReportPrefs}. Detection is by the
- * grouped-only keys, so it can never misread a flat blob as grouped (or vice
- * versa), and it needs no data migration — existing rows resolve correctly.
- */
-export function resolveStoredReportSections(raw: unknown): DoctorReportPrefs {
- if (raw != null && typeof raw === "object" && !Array.isArray(raw)) {
- const obj = raw as Record;
- const looksGrouped = GROUPED_SECTION_KEYS.some((k) => k in obj);
- if (looksGrouped) {
- const parsed = exportSectionsSchema.safeParse(obj);
- // A well-formed grouped blob folds down; a drifted one falls through to
- // the flat parser, which itself defaults on any shape it cannot read.
- if (parsed.success) return toDoctorReportPrefs(parsed.data);
- }
- }
- return parseDoctorReportPrefs(raw);
-}
-
-/**
- * Flagship export selection. `.strict()` so unknown keys (including any
- * attempt to smuggle a `userId`) 422 via `returnAllZodIssues`.
- */
export const exportSelectionSchema = z
.object({
format: z.enum(["pdf", "fhir", "package"]),
@@ -161,11 +34,11 @@ export const exportSelectionSchema = z
})
.strict()
.optional(),
- sections: exportSectionsSchema.optional(),
+ /** The leaf inclusion list. Required: a scope has to be stated. */
+ selection: reportSelectionSchema,
locale: z.enum(locales).optional(),
practiceName: z.string().max(120).optional(),
includeCharts: z.boolean().optional(),
- includeAiSummary: z.boolean().optional(),
// FHIR only: additionally emit the German BfArM ATC URI alongside the WHO
// entry on each medication concept (WHO stays first, byte-identical). When
// omitted, the route derives it from a German-region locale.
@@ -174,60 +47,3 @@ export const exportSelectionSchema = z
.strict();
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, 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 ?? {};
-
- const fallback = DEFAULT_DOCTOR_REPORT_PREFS;
- return {
- weight: v.weight ?? fallback.weight,
- bp: v.bp ?? fallback.bp,
- pulse: v.pulse ?? fallback.pulse,
- bmi: s.bmi ?? fallback.bmi,
- // Mood is opt-in: only true when explicitly requested.
- mood: s.mood === true,
- 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,
- // Cycle is opt-in: only true when explicitly requested (privacy).
- cycle: s.cycle === true,
- labs: s.labs ?? fallback.labs,
- allergies: s.allergies ?? fallback.allergies,
- familyHistory: s.familyHistory ?? fallback.familyHistory,
- oxygenSaturation: v.oxygenSaturation ?? fallback.oxygenSaturation,
- bodyFat: v.bodyFat ?? fallback.bodyFat,
- bodyComposition: v.bodyComposition ?? fallback.bodyComposition,
- restingHeartRate: c.restingHeartRate ?? fallback.restingHeartRate,
- hrv: c.hrv ?? fallback.hrv,
- vo2max: c.vo2max ?? fallback.vo2max,
- steps: a.steps ?? fallback.steps,
- distance: a.distance ?? fallback.distance,
- // Screening totals are opt-in: only true when explicitly requested.
- mentalHealthScreeners: s.mentalHealthScreeners === true,
- };
-}
From 582db8a53895f9a199f9be067cafa410f8850718 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 18:00:14 +0200
Subject: [PATCH 04/17] feat(export): one grouped scope picker, on both
surfaces that choose a scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.
The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.
First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.
Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.
The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.
The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.
The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
---
messages/de.json | 119 +++--
messages/en.json | 139 +++--
messages/es.json | 115 +++--
messages/fr.json | 111 ++--
messages/it.json | 131 +++--
messages/pl.json | 143 ++++--
src/app/api/auth/me/route.ts | 5 +
src/components/settings/export-section.tsx | 118 +----
.../settings/gesundheitsakte-section.tsx | 2 +-
.../settings/health-record-export-controls.ts | 102 ----
.../settings/health-record-export-panel.tsx | 477 ------------------
.../settings/report-selection/group-row.tsx | 105 ++++
.../settings/report-selection/leaf-grid.tsx | 57 +++
.../report-selection/report-scope-picker.tsx | 128 +++++
.../report-selection/scope-summary.tsx | 51 ++
.../report-selection/selection-panel.tsx | 384 ++++++++++++++
.../settings/share-link-create-form.tsx | 71 ++-
src/components/settings/sharing-section.tsx | 20 +-
src/components/ui/checkbox.tsx | 15 +-
src/hooks/use-auth.ts | 7 +
src/lib/report-selection/panel-state.ts | 124 +++++
21 files changed, 1409 insertions(+), 1015 deletions(-)
delete mode 100644 src/components/settings/health-record-export-controls.ts
delete mode 100644 src/components/settings/health-record-export-panel.tsx
create mode 100644 src/components/settings/report-selection/group-row.tsx
create mode 100644 src/components/settings/report-selection/leaf-grid.tsx
create mode 100644 src/components/settings/report-selection/report-scope-picker.tsx
create mode 100644 src/components/settings/report-selection/scope-summary.tsx
create mode 100644 src/components/settings/report-selection/selection-panel.tsx
create mode 100644 src/lib/report-selection/panel-state.ts
diff --git a/messages/de.json b/messages/de.json
index 7e30c8472..fa6e0151e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -53,11 +53,15 @@
},
"apiErrors": {
"measurement": {
- "create": { "invalid": "Prüfe die Messdaten und versuche es erneut." },
+ "create": {
+ "invalid": "Prüfe die Messdaten und versuche es erneut."
+ },
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits eine Messung vorhanden."
},
"mood": {
- "list": { "invalid": "Die Filter der Stimmungsliste sind ungültig." },
+ "list": {
+ "invalid": "Die Filter der Stimmungsliste sind ungültig."
+ },
"create": {
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
},
@@ -71,8 +75,12 @@
}
},
"labs": {
- "create": { "invalid": "Prüfe den Laborwert und versuche es erneut." },
- "result": { "notFound": "Dieser Laborwert ist nicht mehr verfügbar." },
+ "create": {
+ "invalid": "Prüfe den Laborwert und versuche es erneut."
+ },
+ "result": {
+ "notFound": "Dieser Laborwert ist nicht mehr verfügbar."
+ },
"biomarker": {
"notFound": "Dieser Biomarker ist nicht mehr verfügbar.",
"duplicate": "Ein Biomarker mit diesem Namen ist bereits vorhanden.",
@@ -102,7 +110,9 @@
"complete": {
"invalid": "Prüfe die Profildaten und versuche es erneut."
},
- "user": { "notFound": "Dein Konto wurde nicht gefunden." },
+ "user": {
+ "notFound": "Dein Konto wurde nicht gefunden."
+ },
"step": {
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es erneut.",
"invalid": "Dieser Onboarding-Schritt konnte nicht gespeichert werden.",
@@ -160,16 +170,6 @@
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
"footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}",
- "typeWeight": "Körpergewicht",
- "typeBpSys": "Systolischer Blutdruck",
- "typeBpDia": "Diastolischer Blutdruck",
- "typePulse": "Ruhepuls",
- "typeBodyFat": "Körperfettanteil",
- "typeSleep": "Schlafdauer",
- "typeSteps": "Aktivität (Schritte)",
- "typeTotalBodyWater": "Gesamtkörperwasser",
- "typeBoneMass": "Knochenmasse",
- "typeOxygenSaturation": "Sauerstoffsättigung",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
@@ -205,8 +205,6 @@
"summaryTrend": "{label}: zuletzt {latest} {arrow}",
"summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).",
"chartsTitle": "Trend-Diagramme",
- "aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
- "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.",
"wellnessTitle": "Wellness-Zusammenfassung",
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
@@ -4447,10 +4445,6 @@
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
- },
- "cycle": {
- "title": "Zyklus",
- "description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase."
}
},
"import": {
@@ -5313,35 +5307,18 @@
"practiceName": "Praxis (optional)",
"practiceNamePlaceholder": "z. B. Dr. med. Mustermann",
"includedData": "Enthaltene Daten",
- "groupVitals": "Vitalwerte",
- "groupCardio": "Herz & Fitness",
- "groupActivity": "Aktivität & Schlaf",
- "weight": "Gewicht",
- "bp": "Blutdruck",
- "pulse": "Puls",
- "oxygenSaturation": "Sauerstoffsättigung",
- "bodyFat": "Körperfett",
- "bodyComposition": "Körperzusammensetzung",
- "restingHeartRate": "Ruhepuls",
- "hrv": "Herzfrequenzvariabilität",
- "vo2max": "VO₂max",
- "steps": "Schritte",
- "distance": "Distanz",
- "sleep": "Schlaf",
- "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)",
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
- "error": "Export fehlgeschlagen ({code}).",
- "labs": "Laborwerte",
- "allergies": "Allergien & Unverträglichkeiten",
- "familyHistory": "Familienanamnese",
- "documentsLink": "Dokumente zum Zeitraum"
+ "documentsLink": "Dokumente zum Zeitraum",
+ "range30": "Letzte 30 Tage",
+ "rangeCustom": "Eigener Zeitraum",
+ "rangeCustomStart": "Von",
+ "rangeCustomEnd": "Bis",
+ "errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
+ "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
+ "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.",
+ "errorEmptySelection": "Wählen Sie mindestens einen Eintrag aus."
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
@@ -5392,7 +5369,11 @@
"pickerEmpty": "Keine Dokumente gefunden.",
"pickerDone": "Fertig",
"pickerSelectedCount": "{count} von {max} ausgewählt",
- "pickerMaxReached": "Maximum von {max} Dokumenten erreicht."
+ "pickerMaxReached": "Maximum von {max} Dokumenten erreicht.",
+ "scopeTitle": "Was dieser Link enthält",
+ "scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
+ "statusNeedsReselect": "Inhalt neu wählen",
+ "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt."
},
"genderOther": "Divers",
"cycleTracking": {
@@ -6928,7 +6909,6 @@
"period": "Berichtszeitraum: {start} – {end}",
"expires": "Dieser Link läuft am {date} ab.",
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
- "vitals": "Vitalwerte",
"labs": "Laborwerte",
"medications": "Medikamente & Therapietreue",
"bmi": "Body-Mass-Index (BMI)",
@@ -6968,7 +6948,10 @@
"download": "Herunterladen",
"attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).",
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
- }
+ },
+ "downloadPdf": "Als PDF herunterladen",
+ "downloadFhir": "Als FHIR herunterladen",
+ "bmiSection": "Body-Mass-Index"
},
"cycle": {
"symptomCategory": {
@@ -8875,5 +8858,41 @@
}
}
}
+ },
+ "reportSelection": {
+ "groupIdentity": "Stammdaten",
+ "groupVitals": "Vitalwerte",
+ "groupCardio": "Herz & Kreislauf",
+ "groupBody": "Körpermaße",
+ "groupSleepRecovery": "Schlaf & Erholung",
+ "groupActivity": "Aktivität",
+ "groupMobility": "Mobilität & Gang",
+ "groupEnvironment": "Umgebung",
+ "groupGlucose": "Blutzucker",
+ "groupLabs": "Laborwerte",
+ "groupMedications": "Medikamente",
+ "groupHistory": "Vorerkrankungen & Allergien",
+ "groupSensitive": "Sensible Daten",
+ "leafPatientIdentity": "Name, Geburtsdatum, Geschlecht, Größe",
+ "leafInsurance": "Krankenkasse und Versichertennummer",
+ "leafGlucosePanel": "Blutzucker-Panel (Zeit im Zielbereich, GMI, Variabilität)",
+ "leafLabResults": "Laborwerte",
+ "leafMedicationList": "Medikamentenliste",
+ "leafMedicationAdministrations": "Jede erfasste Einnahme",
+ "leafMedicationCompliance": "Einnahmetreue",
+ "leafGlp1Therapy": "GLP-1-Therapie (Dosisverlauf, Nebenwirkungen)",
+ "leafAllergies": "Allergien und Unverträglichkeiten",
+ "leafIllnessEpisodes": "Krankheitsepisoden",
+ "leafFamilyHistory": "Familienanamnese",
+ "leafMood": "Stimmung",
+ "leafCycle": "Menstruationszyklus",
+ "templateStandard": "Standard-Arztbericht",
+ "templateApplied": "{template} ist vorausgewählt. Prüfen Sie ihn vor dem Erstellen.",
+ "groupCount": "{on}/{total}",
+ "groupDisclosure": "Einträge in {group} anzeigen",
+ "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
+ "scopeNone": "Nichts ausgewählt — das Dokument wäre leer.",
+ "scopeCount": "{count} Einträge aus {groups} Bereichen",
+ "scopeSensitive": "inkl. {names}"
}
}
diff --git a/messages/en.json b/messages/en.json
index 4adcf54d7..65b5cfbac 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -53,13 +53,21 @@
},
"apiErrors": {
"measurement": {
- "create": { "invalid": "Check the measurement details and try again." },
+ "create": {
+ "invalid": "Check the measurement details and try again."
+ },
"duplicate_timestamp": "A measurement already exists for this time."
},
"mood": {
- "list": { "invalid": "The mood list filters are invalid." },
- "create": { "invalid": "Check the mood entry and try again." },
- "update": { "invalid": "Check the mood entry and try again." },
+ "list": {
+ "invalid": "The mood list filters are invalid."
+ },
+ "create": {
+ "invalid": "Check the mood entry and try again."
+ },
+ "update": {
+ "invalid": "Check the mood entry and try again."
+ },
"duplicate_timestamp": "A mood entry already exists for this time.",
"not_found": "This mood entry is no longer available.",
"ratedFactor": {
@@ -67,12 +75,18 @@
}
},
"labs": {
- "create": { "invalid": "Check the lab result and try again." },
- "result": { "notFound": "This lab result is no longer available." },
+ "create": {
+ "invalid": "Check the lab result and try again."
+ },
+ "result": {
+ "notFound": "This lab result is no longer available."
+ },
"biomarker": {
"notFound": "This biomarker is no longer available.",
"duplicate": "A biomarker with this name already exists.",
- "create": { "invalid": "Check the biomarker details and try again." },
+ "create": {
+ "invalid": "Check the biomarker details and try again."
+ },
"update": {
"invalid": "Check the biomarker details and try again.",
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
@@ -90,9 +104,15 @@
"disclaimer": {
"invalid": "Please confirm the medical disclaimer again."
},
- "tour": { "invalid": "The tour progress could not be saved." },
- "complete": { "invalid": "Check the profile details and try again." },
- "user": { "notFound": "Your account could not be found." },
+ "tour": {
+ "invalid": "The tour progress could not be saved."
+ },
+ "complete": {
+ "invalid": "Check the profile details and try again."
+ },
+ "user": {
+ "notFound": "Your account could not be found."
+ },
"step": {
"rateLimited": "Too many attempts. Please wait and try again.",
"invalid": "This onboarding step could not be saved.",
@@ -150,16 +170,6 @@
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
"footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}",
- "typeWeight": "Body weight",
- "typeBpSys": "Systolic blood pressure",
- "typeBpDia": "Diastolic blood pressure",
- "typePulse": "Resting pulse",
- "typeBodyFat": "Body fat",
- "typeSleep": "Sleep duration",
- "typeSteps": "Activity (steps)",
- "typeTotalBodyWater": "Total body water",
- "typeBoneMass": "Bone mass",
- "typeOxygenSaturation": "Oxygen saturation",
"unitSteps": "steps",
"bmiUnderweight": "Underweight",
"bmiNormal": "Normal weight",
@@ -195,8 +205,6 @@
"summaryTrend": "{label}: latest {latest} {arrow}",
"summaryAdherence": "Medication adherence: {rate}% (weighted mean).",
"chartsTitle": "Trend charts",
- "aiSummaryTitle": "AI summary — not clinically validated",
- "aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.",
"wellnessTitle": "Wellness summary",
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
@@ -4437,10 +4445,6 @@
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
"stepUpNeeded": "Confirm your second factor, then try again."
- },
- "cycle": {
- "title": "Cycle",
- "description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase."
}
},
"import": {
@@ -5303,35 +5307,18 @@
"practiceName": "Practice (optional)",
"practiceNamePlaceholder": "e.g. Dr. Smith",
"includedData": "Included data",
- "groupVitals": "Vital signs",
- "groupCardio": "Heart & fitness",
- "groupActivity": "Activity & sleep",
- "weight": "Weight",
- "bp": "Blood pressure",
- "pulse": "Pulse",
- "oxygenSaturation": "Oxygen saturation",
- "bodyFat": "Body fat",
- "bodyComposition": "Body composition",
- "restingHeartRate": "Resting heart rate",
- "hrv": "Heart rate variability",
- "vo2max": "VO₂max",
- "steps": "Steps",
- "distance": "Distance",
- "sleep": "Sleep",
- "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)",
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
- "error": "Export failed ({code}).",
- "labs": "Lab results",
- "allergies": "Allergies & intolerances",
- "familyHistory": "Family history",
- "documentsLink": "Documents for this period"
+ "documentsLink": "Documents for this period",
+ "range30": "Last 30 days",
+ "rangeCustom": "Custom period",
+ "rangeCustomStart": "From",
+ "rangeCustomEnd": "To",
+ "errorGeneric": "The export could not be generated. Please try again.",
+ "errorRateLimit": "You have reached the export limit for this hour.",
+ "errorModuleDisabled": "The health record export is switched off for this account.",
+ "errorEmptySelection": "Pick at least one entry to include."
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
@@ -5382,7 +5369,11 @@
"pickerEmpty": "No documents found.",
"pickerDone": "Done",
"pickerSelectedCount": "{count} of {max} selected",
- "pickerMaxReached": "You've reached the maximum of {max} documents."
+ "pickerMaxReached": "You've reached the maximum of {max} documents.",
+ "scopeTitle": "What this link contains",
+ "scopeInsuranceExcluded": "Insurance details are never shared through a link.",
+ "statusNeedsReselect": "Pick contents again",
+ "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain."
},
"genderOther": "Other",
"cycleTracking": {
@@ -6918,7 +6909,6 @@
"period": "Reporting period: {start} – {end}",
"expires": "This link expires on {date}.",
"provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.",
- "vitals": "Vital signs",
"labs": "Lab values",
"medications": "Medications & adherence",
"bmi": "Body mass index (BMI)",
@@ -6958,7 +6948,10 @@
"download": "Download",
"attachmentHint": "This file downloads to your device (no in-page preview).",
"exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images."
- }
+ },
+ "downloadPdf": "Download as PDF",
+ "downloadFhir": "Download as FHIR",
+ "bmiSection": "Body mass index"
},
"cycle": {
"symptomCategory": {
@@ -8865,5 +8858,41 @@
}
}
}
+ },
+ "reportSelection": {
+ "groupIdentity": "Personal details",
+ "groupVitals": "Vital signs",
+ "groupCardio": "Heart & circulation",
+ "groupBody": "Body measurements",
+ "groupSleepRecovery": "Sleep & recovery",
+ "groupActivity": "Activity",
+ "groupMobility": "Mobility & gait",
+ "groupEnvironment": "Environment",
+ "groupGlucose": "Blood glucose",
+ "groupLabs": "Lab results",
+ "groupMedications": "Medications",
+ "groupHistory": "Conditions & allergies",
+ "groupSensitive": "Sensitive data",
+ "leafPatientIdentity": "Name, date of birth, gender, height",
+ "leafInsurance": "Insurer and insurance number",
+ "leafGlucosePanel": "Glucose panel (time in range, GMI, variability)",
+ "leafLabResults": "Lab results",
+ "leafMedicationList": "Medication list",
+ "leafMedicationAdministrations": "Every logged dose",
+ "leafMedicationCompliance": "Adherence rate",
+ "leafGlp1Therapy": "GLP-1 therapy (dose history, side effects)",
+ "leafAllergies": "Allergies and intolerances",
+ "leafIllnessEpisodes": "Illness episodes",
+ "leafFamilyHistory": "Family history",
+ "leafMood": "Mood",
+ "leafCycle": "Menstrual cycle",
+ "templateStandard": "Standard doctor's report",
+ "templateApplied": "{template} is preselected. Check it before you generate.",
+ "groupCount": "{on}/{total}",
+ "groupDisclosure": "Show the entries in {group}",
+ "sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
+ "scopeNone": "Nothing selected — the document would be empty.",
+ "scopeCount": "{count} entries from {groups} areas",
+ "scopeSensitive": "incl. {names}"
}
}
diff --git a/messages/es.json b/messages/es.json
index 5b39c06ac..7fe4c4e81 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -59,7 +59,9 @@
"duplicate_timestamp": "Ya existe una medición para esta hora."
},
"mood": {
- "list": { "invalid": "Los filtros de la lista de ánimo no son válidos." },
+ "list": {
+ "invalid": "Los filtros de la lista de ánimo no son válidos."
+ },
"create": {
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
},
@@ -99,12 +101,18 @@
}
},
"onboarding": {
- "disclaimer": { "invalid": "Confirma de nuevo el aviso médico." },
- "tour": { "invalid": "No se pudo guardar el progreso del recorrido." },
+ "disclaimer": {
+ "invalid": "Confirma de nuevo el aviso médico."
+ },
+ "tour": {
+ "invalid": "No se pudo guardar el progreso del recorrido."
+ },
"complete": {
"invalid": "Comprueba los datos del perfil e inténtalo de nuevo."
},
- "user": { "notFound": "No se pudo encontrar tu cuenta." },
+ "user": {
+ "notFound": "No se pudo encontrar tu cuenta."
+ },
"step": {
"rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.",
"invalid": "No se pudo guardar este paso de la introducción.",
@@ -162,16 +170,6 @@
"footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
"footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
"footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}",
- "typeWeight": "Peso corporal",
- "typeBpSys": "Tensión arterial sistólica",
- "typeBpDia": "Tensión arterial diastólica",
- "typePulse": "Pulso en reposo",
- "typeBodyFat": "Grasa corporal",
- "typeSleep": "Duración del sueño",
- "typeSteps": "Actividad (pasos)",
- "typeTotalBodyWater": "Agua corporal total",
- "typeBoneMass": "Masa ósea",
- "typeOxygenSaturation": "Saturación de oxígeno",
"unitSteps": "Pasos",
"bmiUnderweight": "Peso insuficiente",
"bmiNormal": "Peso normal",
@@ -207,8 +205,6 @@
"summaryTrend": "{label}: último {latest} {arrow}",
"summaryAdherence": "Adherencia a la medicación: {rate}% (media ponderada).",
"chartsTitle": "Gráficos de tendencia",
- "aiSummaryTitle": "Resumen de IA — no validado clínicamente",
- "aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.",
"wellnessTitle": "Resumen de bienestar",
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
@@ -4449,10 +4445,6 @@
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
- },
- "cycle": {
- "title": "Ciclo",
- "description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual."
}
},
"import": {
@@ -5315,35 +5307,18 @@
"practiceName": "Consulta (opcional)",
"practiceNamePlaceholder": "p. ej. Dr. García",
"includedData": "Datos incluidos",
- "groupVitals": "Signos vitales",
- "groupCardio": "Corazón y forma física",
- "groupActivity": "Actividad y sueño",
- "weight": "Peso",
- "bp": "Presión arterial",
- "pulse": "Pulso",
- "oxygenSaturation": "Saturación de oxígeno",
- "bodyFat": "Grasa corporal",
- "bodyComposition": "Composición corporal",
- "restingHeartRate": "Pulso en reposo",
- "hrv": "Variabilidad de la frecuencia cardíaca",
- "vo2max": "VO₂máx",
- "steps": "Pasos",
- "distance": "Distancia",
- "sleep": "Sueño",
- "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)",
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
- "error": "Error en la exportación ({code}).",
- "labs": "Resultados de análisis",
- "allergies": "Alergias e intolerancias",
- "familyHistory": "Antecedentes familiares",
- "documentsLink": "Documentos del período"
+ "documentsLink": "Documentos del período",
+ "range30": "Últimos 30 días",
+ "rangeCustom": "Periodo personalizado",
+ "rangeCustomStart": "Desde",
+ "rangeCustomEnd": "Hasta",
+ "errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
+ "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
+ "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.",
+ "errorEmptySelection": "Seleccione al menos una entrada."
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
@@ -5394,7 +5369,11 @@
"pickerEmpty": "No se encontraron documentos.",
"pickerDone": "Listo",
"pickerSelectedCount": "{count} de {max} seleccionados",
- "pickerMaxReached": "Has alcanzado el máximo de {max} documentos."
+ "pickerMaxReached": "Has alcanzado el máximo de {max} documentos.",
+ "scopeTitle": "Qué contiene este enlace",
+ "scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
+ "statusNeedsReselect": "Elegir contenido de nuevo",
+ "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido."
},
"genderOther": "Otro",
"cycleTracking": {
@@ -6930,7 +6909,6 @@
"period": "Período del informe: {start} – {end}",
"expires": "Este enlace caduca el {date}.",
"provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.",
- "vitals": "Signos vitales",
"labs": "Valores de laboratorio",
"medications": "Medicamentos y adherencia",
"bmi": "Índice de masa corporal (IMC)",
@@ -6970,7 +6948,10 @@
"download": "Descargar",
"attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).",
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas."
- }
+ },
+ "downloadPdf": "Descargar como PDF",
+ "downloadFhir": "Descargar como FHIR",
+ "bmiSection": "Índice de masa corporal"
},
"cycle": {
"symptomCategory": {
@@ -8877,5 +8858,41 @@
}
}
}
+ },
+ "reportSelection": {
+ "groupIdentity": "Datos personales",
+ "groupVitals": "Signos vitales",
+ "groupCardio": "Corazón y circulación",
+ "groupBody": "Medidas corporales",
+ "groupSleepRecovery": "Sueño y recuperación",
+ "groupActivity": "Actividad",
+ "groupMobility": "Movilidad y marcha",
+ "groupEnvironment": "Entorno",
+ "groupGlucose": "Glucosa en sangre",
+ "groupLabs": "Resultados de laboratorio",
+ "groupMedications": "Medicamentos",
+ "groupHistory": "Antecedentes y alergias",
+ "groupSensitive": "Datos sensibles",
+ "leafPatientIdentity": "Nombre, fecha de nacimiento, sexo, altura",
+ "leafInsurance": "Aseguradora y número de seguro",
+ "leafGlucosePanel": "Panel de glucosa (tiempo en rango, GMI, variabilidad)",
+ "leafLabResults": "Resultados de laboratorio",
+ "leafMedicationList": "Lista de medicamentos",
+ "leafMedicationAdministrations": "Cada dosis registrada",
+ "leafMedicationCompliance": "Adherencia",
+ "leafGlp1Therapy": "Terapia GLP-1 (historial de dosis, efectos secundarios)",
+ "leafAllergies": "Alergias e intolerancias",
+ "leafIllnessEpisodes": "Episodios de enfermedad",
+ "leafFamilyHistory": "Antecedentes familiares",
+ "leafMood": "Estado de ánimo",
+ "leafCycle": "Ciclo menstrual",
+ "templateStandard": "Informe médico estándar",
+ "templateApplied": "{template} está preseleccionado. Revíselo antes de generar.",
+ "groupCount": "{on}/{total}",
+ "groupDisclosure": "Mostrar las entradas de {group}",
+ "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
+ "scopeNone": "Nada seleccionado: el documento estaría vacío.",
+ "scopeCount": "{count} entradas de {groups} áreas",
+ "scopeSensitive": "incl. {names}"
}
}
diff --git a/messages/fr.json b/messages/fr.json
index c44e23a39..b2235e769 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -62,8 +62,12 @@
"list": {
"invalid": "Les filtres de la liste d’humeur ne sont pas valides."
},
- "create": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
- "update": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
+ "create": {
+ "invalid": "Vérifiez l’entrée d’humeur et réessayez."
+ },
+ "update": {
+ "invalid": "Vérifiez l’entrée d’humeur et réessayez."
+ },
"duplicate_timestamp": "Une entrée d’humeur existe déjà pour cette heure.",
"not_found": "Cette entrée d’humeur n’est plus disponible.",
"ratedFactor": {
@@ -106,7 +110,9 @@
"complete": {
"invalid": "Vérifiez les informations du profil et réessayez."
},
- "user": { "notFound": "Votre compte est introuvable." },
+ "user": {
+ "notFound": "Votre compte est introuvable."
+ },
"step": {
"rateLimited": "Trop de tentatives. Patientez puis réessayez.",
"invalid": "Cette étape d’accueil n’a pas pu être enregistrée.",
@@ -164,16 +170,6 @@
"footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
"footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
"footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}",
- "typeWeight": "Poids corporel",
- "typeBpSys": "Tension artérielle systolique",
- "typeBpDia": "Tension artérielle diastolique",
- "typePulse": "Pouls au repos",
- "typeBodyFat": "Masse grasse",
- "typeSleep": "Durée du sommeil",
- "typeSteps": "Activité (pas)",
- "typeTotalBodyWater": "Eau corporelle totale",
- "typeBoneMass": "Masse osseuse",
- "typeOxygenSaturation": "Saturation en oxygène",
"unitSteps": "Pas",
"bmiUnderweight": "Insuffisance pondérale",
"bmiNormal": "Poids normal",
@@ -209,8 +205,6 @@
"summaryTrend": "{label} : dernier {latest} {arrow}",
"summaryAdherence": "Observance médicamenteuse : {rate}% (moyenne pondérée).",
"chartsTitle": "Graphiques de tendance",
- "aiSummaryTitle": "Résumé IA — non validé cliniquement",
- "aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.",
"wellnessTitle": "Résumé de bien-être",
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
@@ -4451,10 +4445,6 @@
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
- },
- "cycle": {
- "title": "Cycle",
- "description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle."
}
},
"import": {
@@ -5317,35 +5307,18 @@
"practiceName": "Cabinet (facultatif)",
"practiceNamePlaceholder": "p. ex. Dr Dupont",
"includedData": "Données incluses",
- "groupVitals": "Signes vitaux",
- "groupCardio": "Cœur & forme",
- "groupActivity": "Activité & sommeil",
- "weight": "Poids",
- "bp": "Tension artérielle",
- "pulse": "Pouls",
- "oxygenSaturation": "Saturation en oxygène",
- "bodyFat": "Masse grasse",
- "bodyComposition": "Composition corporelle",
- "restingHeartRate": "Pouls au repos",
- "hrv": "Variabilité de la fréquence cardiaque",
- "vo2max": "VO₂max",
- "steps": "Pas",
- "distance": "Distance",
- "sleep": "Sommeil",
- "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)",
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
- "error": "Échec de l’export ({code}).",
- "labs": "Résultats d'analyses",
- "allergies": "Allergies et intolérances",
- "familyHistory": "Antécédents familiaux",
- "documentsLink": "Documents de la période"
+ "documentsLink": "Documents de la période",
+ "range30": "30 derniers jours",
+ "rangeCustom": "Période personnalisée",
+ "rangeCustomStart": "Du",
+ "rangeCustomEnd": "Au",
+ "errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
+ "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
+ "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.",
+ "errorEmptySelection": "Sélectionnez au moins un élément."
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
@@ -5396,7 +5369,11 @@
"pickerEmpty": "Aucun document trouvé.",
"pickerDone": "Terminé",
"pickerSelectedCount": "{count} sur {max} sélectionnés",
- "pickerMaxReached": "Vous avez atteint le maximum de {max} documents."
+ "pickerMaxReached": "Vous avez atteint le maximum de {max} documents.",
+ "scopeTitle": "Ce que contient ce lien",
+ "scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
+ "statusNeedsReselect": "Choisir à nouveau le contenu",
+ "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu."
},
"genderOther": "Autre",
"cycleTracking": {
@@ -6932,7 +6909,6 @@
"period": "Période couverte : {start} – {end}",
"expires": "Ce lien expire le {date}.",
"provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.",
- "vitals": "Signes vitaux",
"labs": "Valeurs de laboratoire",
"medications": "Médicaments et observance",
"bmi": "Indice de masse corporelle (IMC)",
@@ -6972,7 +6948,10 @@
"download": "Télécharger",
"attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).",
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées."
- }
+ },
+ "downloadPdf": "Télécharger en PDF",
+ "downloadFhir": "Télécharger en FHIR",
+ "bmiSection": "Indice de masse corporelle"
},
"cycle": {
"symptomCategory": {
@@ -8879,5 +8858,41 @@
}
}
}
+ },
+ "reportSelection": {
+ "groupIdentity": "Données personnelles",
+ "groupVitals": "Signes vitaux",
+ "groupCardio": "Cœur et circulation",
+ "groupBody": "Mensurations",
+ "groupSleepRecovery": "Sommeil et récupération",
+ "groupActivity": "Activité",
+ "groupMobility": "Mobilité et marche",
+ "groupEnvironment": "Environnement",
+ "groupGlucose": "Glycémie",
+ "groupLabs": "Résultats de laboratoire",
+ "groupMedications": "Médicaments",
+ "groupHistory": "Antécédents et allergies",
+ "groupSensitive": "Données sensibles",
+ "leafPatientIdentity": "Nom, date de naissance, sexe, taille",
+ "leafInsurance": "Assurance et numéro d'assuré",
+ "leafGlucosePanel": "Bilan glycémique (temps dans la cible, GMI, variabilité)",
+ "leafLabResults": "Résultats de laboratoire",
+ "leafMedicationList": "Liste des médicaments",
+ "leafMedicationAdministrations": "Chaque prise enregistrée",
+ "leafMedicationCompliance": "Observance",
+ "leafGlp1Therapy": "Traitement GLP-1 (historique des doses, effets indésirables)",
+ "leafAllergies": "Allergies et intolérances",
+ "leafIllnessEpisodes": "Épisodes de maladie",
+ "leafFamilyHistory": "Antécédents familiaux",
+ "leafMood": "Humeur",
+ "leafCycle": "Cycle menstruel",
+ "templateStandard": "Compte rendu médical standard",
+ "templateApplied": "{template} est présélectionné. Vérifiez-le avant de générer.",
+ "groupCount": "{on}/{total}",
+ "groupDisclosure": "Afficher les éléments de {group}",
+ "sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
+ "scopeNone": "Rien de sélectionné — le document serait vide.",
+ "scopeCount": "{count} éléments issus de {groups} domaines",
+ "scopeSensitive": "dont {names}"
}
}
diff --git a/messages/it.json b/messages/it.json
index c562f129e..177128bdc 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -53,13 +53,21 @@
},
"apiErrors": {
"measurement": {
- "create": { "invalid": "Controlla i dati della misurazione e riprova." },
+ "create": {
+ "invalid": "Controlla i dati della misurazione e riprova."
+ },
"duplicate_timestamp": "Esiste già una misurazione per questo orario."
},
"mood": {
- "list": { "invalid": "I filtri dell’elenco dell’umore non sono validi." },
- "create": { "invalid": "Controlla la voce dell’umore e riprova." },
- "update": { "invalid": "Controlla la voce dell’umore e riprova." },
+ "list": {
+ "invalid": "I filtri dell’elenco dell’umore non sono validi."
+ },
+ "create": {
+ "invalid": "Controlla la voce dell’umore e riprova."
+ },
+ "update": {
+ "invalid": "Controlla la voce dell’umore e riprova."
+ },
"duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.",
"not_found": "Questa voce dell’umore non è più disponibile.",
"ratedFactor": {
@@ -76,7 +84,9 @@
"biomarker": {
"notFound": "Questo biomarcatore non è più disponibile.",
"duplicate": "Esiste già un biomarcatore con questo nome.",
- "create": { "invalid": "Controlla i dati del biomarcatore e riprova." },
+ "create": {
+ "invalid": "Controlla i dati del biomarcatore e riprova."
+ },
"update": {
"invalid": "Controlla i dati del biomarcatore e riprova.",
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
@@ -91,12 +101,18 @@
}
},
"onboarding": {
- "disclaimer": { "invalid": "Conferma nuovamente l’avviso medico." },
+ "disclaimer": {
+ "invalid": "Conferma nuovamente l’avviso medico."
+ },
"tour": {
"invalid": "Non è stato possibile salvare l’avanzamento del tour."
},
- "complete": { "invalid": "Controlla i dati del profilo e riprova." },
- "user": { "notFound": "Impossibile trovare il tuo account." },
+ "complete": {
+ "invalid": "Controlla i dati del profilo e riprova."
+ },
+ "user": {
+ "notFound": "Impossibile trovare il tuo account."
+ },
"step": {
"rateLimited": "Troppi tentativi. Attendi e riprova.",
"invalid": "Non è stato possibile salvare questo passaggio introduttivo.",
@@ -154,16 +170,6 @@
"footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
"footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
"footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}",
- "typeWeight": "Peso corporeo",
- "typeBpSys": "Pressione arteriosa sistolica",
- "typeBpDia": "Pressione arteriosa diastolica",
- "typePulse": "Pulsazioni a riposo",
- "typeBodyFat": "Massa grassa",
- "typeSleep": "Durata del sonno",
- "typeSteps": "Attività (passi)",
- "typeTotalBodyWater": "Acqua corporea totale",
- "typeBoneMass": "Massa ossea",
- "typeOxygenSaturation": "Saturazione di ossigeno",
"unitSteps": "Passi",
"bmiUnderweight": "Sottopeso",
"bmiNormal": "Peso normale",
@@ -199,8 +205,6 @@
"summaryTrend": "{label}: ultimo {latest} {arrow}",
"summaryAdherence": "Aderenza ai farmaci: {rate}% (media ponderata).",
"chartsTitle": "Grafici di tendenza",
- "aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
- "aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.",
"wellnessTitle": "Riepilogo del benessere",
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
@@ -4441,10 +4445,6 @@
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
"stepUpNeeded": "Conferma il secondo fattore e riprova."
- },
- "cycle": {
- "title": "Ciclo",
- "description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale."
}
},
"import": {
@@ -5307,35 +5307,18 @@
"practiceName": "Studio (facoltativo)",
"practiceNamePlaceholder": "es. Dr. Rossi",
"includedData": "Dati inclusi",
- "groupVitals": "Parametri vitali",
- "groupCardio": "Cuore & fitness",
- "groupActivity": "Attività & sonno",
- "weight": "Peso",
- "bp": "Pressione sanguigna",
- "pulse": "Polso",
- "oxygenSaturation": "Saturazione di ossigeno",
- "bodyFat": "Massa grassa",
- "bodyComposition": "Composizione corporea",
- "restingHeartRate": "Frequenza a riposo",
- "hrv": "Variabilità della frequenza cardiaca",
- "vo2max": "VO₂max",
- "steps": "Passi",
- "distance": "Distanza",
- "sleep": "Sonno",
- "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)",
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
- "error": "Esportazione non riuscita ({code}).",
- "labs": "Risultati degli esami",
- "allergies": "Allergie e intolleranze",
- "familyHistory": "Anamnesi familiare",
- "documentsLink": "Documenti del periodo"
+ "documentsLink": "Documenti del periodo",
+ "range30": "Ultimi 30 giorni",
+ "rangeCustom": "Periodo personalizzato",
+ "rangeCustomStart": "Dal",
+ "rangeCustomEnd": "Al",
+ "errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
+ "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
+ "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.",
+ "errorEmptySelection": "Seleziona almeno una voce."
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
@@ -5386,7 +5369,11 @@
"pickerEmpty": "Nessun documento trovato.",
"pickerDone": "Fatto",
"pickerSelectedCount": "{count} di {max} selezionati",
- "pickerMaxReached": "Hai raggiunto il massimo di {max} documenti."
+ "pickerMaxReached": "Hai raggiunto il massimo di {max} documenti.",
+ "scopeTitle": "Cosa contiene questo link",
+ "scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
+ "statusNeedsReselect": "Scegli di nuovo il contenuto",
+ "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere."
},
"genderOther": "Altro",
"cycleTracking": {
@@ -6922,7 +6909,6 @@
"period": "Periodo del report: {start} – {end}",
"expires": "Questo link scade il {date}.",
"provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.",
- "vitals": "Parametri vitali",
"labs": "Valori di laboratorio",
"medications": "Farmaci e aderenza",
"bmi": "Indice di massa corporea (IMC)",
@@ -6962,7 +6948,10 @@
"download": "Scarica",
"attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).",
"exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise."
- }
+ },
+ "downloadPdf": "Scarica come PDF",
+ "downloadFhir": "Scarica come FHIR",
+ "bmiSection": "Indice di massa corporea"
},
"cycle": {
"symptomCategory": {
@@ -8869,5 +8858,41 @@
}
}
}
+ },
+ "reportSelection": {
+ "groupIdentity": "Dati anagrafici",
+ "groupVitals": "Parametri vitali",
+ "groupCardio": "Cuore e circolazione",
+ "groupBody": "Misure corporee",
+ "groupSleepRecovery": "Sonno e recupero",
+ "groupActivity": "Attività",
+ "groupMobility": "Mobilità e andatura",
+ "groupEnvironment": "Ambiente",
+ "groupGlucose": "Glicemia",
+ "groupLabs": "Esami di laboratorio",
+ "groupMedications": "Farmaci",
+ "groupHistory": "Patologie e allergie",
+ "groupSensitive": "Dati sensibili",
+ "leafPatientIdentity": "Nome, data di nascita, sesso, altezza",
+ "leafInsurance": "Assicurazione e numero di iscrizione",
+ "leafGlucosePanel": "Pannello glicemico (tempo nel range, GMI, variabilità)",
+ "leafLabResults": "Esami di laboratorio",
+ "leafMedicationList": "Elenco dei farmaci",
+ "leafMedicationAdministrations": "Ogni dose registrata",
+ "leafMedicationCompliance": "Aderenza",
+ "leafGlp1Therapy": "Terapia GLP-1 (storico dosi, effetti collaterali)",
+ "leafAllergies": "Allergie e intolleranze",
+ "leafIllnessEpisodes": "Episodi di malattia",
+ "leafFamilyHistory": "Anamnesi familiare",
+ "leafMood": "Umore",
+ "leafCycle": "Ciclo mestruale",
+ "templateStandard": "Referto medico standard",
+ "templateApplied": "{template} è preselezionato. Controllalo prima di generare.",
+ "groupCount": "{on}/{total}",
+ "groupDisclosure": "Mostra le voci di {group}",
+ "sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
+ "scopeNone": "Niente selezionato: il documento sarebbe vuoto.",
+ "scopeCount": "{count} voci da {groups} aree",
+ "scopeSensitive": "incl. {names}"
}
}
diff --git a/messages/pl.json b/messages/pl.json
index 48821edd1..613298b0d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -53,13 +53,21 @@
},
"apiErrors": {
"measurement": {
- "create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." },
+ "create": {
+ "invalid": "Sprawdź dane pomiaru i spróbuj ponownie."
+ },
"duplicate_timestamp": "Pomiar dla tego czasu już istnieje."
},
"mood": {
- "list": { "invalid": "Filtry listy nastroju są nieprawidłowe." },
- "create": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
- "update": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
+ "list": {
+ "invalid": "Filtry listy nastroju są nieprawidłowe."
+ },
+ "create": {
+ "invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
+ },
+ "update": {
+ "invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
+ },
"duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.",
"not_found": "Ten wpis nastroju nie jest już dostępny.",
"ratedFactor": {
@@ -67,12 +75,18 @@
}
},
"labs": {
- "create": { "invalid": "Sprawdź wynik badania i spróbuj ponownie." },
- "result": { "notFound": "Ten wynik badania nie jest już dostępny." },
+ "create": {
+ "invalid": "Sprawdź wynik badania i spróbuj ponownie."
+ },
+ "result": {
+ "notFound": "Ten wynik badania nie jest już dostępny."
+ },
"biomarker": {
"notFound": "Ten biomarker nie jest już dostępny.",
"duplicate": "Biomarker o tej nazwie już istnieje.",
- "create": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." },
+ "create": {
+ "invalid": "Sprawdź dane biomarkera i spróbuj ponownie."
+ },
"update": {
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie.",
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
@@ -87,10 +101,18 @@
}
},
"onboarding": {
- "disclaimer": { "invalid": "Potwierdź ponownie informację medyczną." },
- "tour": { "invalid": "Nie udało się zapisać postępu prezentacji." },
- "complete": { "invalid": "Sprawdź dane profilu i spróbuj ponownie." },
- "user": { "notFound": "Nie znaleziono Twojego konta." },
+ "disclaimer": {
+ "invalid": "Potwierdź ponownie informację medyczną."
+ },
+ "tour": {
+ "invalid": "Nie udało się zapisać postępu prezentacji."
+ },
+ "complete": {
+ "invalid": "Sprawdź dane profilu i spróbuj ponownie."
+ },
+ "user": {
+ "notFound": "Nie znaleziono Twojego konta."
+ },
"step": {
"rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.",
"invalid": "Nie udało się zapisać tego kroku wprowadzenia.",
@@ -148,16 +170,6 @@
"footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
"footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
"footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}",
- "typeWeight": "Masa ciała",
- "typeBpSys": "Ciśnienie skurczowe",
- "typeBpDia": "Ciśnienie rozkurczowe",
- "typePulse": "Tętno spoczynkowe",
- "typeBodyFat": "Tkanka tłuszczowa",
- "typeSleep": "Czas snu",
- "typeSteps": "Aktywność (kroki)",
- "typeTotalBodyWater": "Całkowita woda w organizmie",
- "typeBoneMass": "Masa kostna",
- "typeOxygenSaturation": "Saturacja tlenem",
"unitSteps": "Kroki",
"bmiUnderweight": "Niedowaga",
"bmiNormal": "Waga prawidłowa",
@@ -193,8 +205,6 @@
"summaryTrend": "{label}: ostatnio {latest} {arrow}",
"summaryAdherence": "Przestrzeganie zaleceń lekowych: {rate}% (średnia ważona).",
"chartsTitle": "Wykresy trendów",
- "aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
- "aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.",
"wellnessTitle": "Podsumowanie dobrostanu",
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
@@ -4435,10 +4445,6 @@
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
- },
- "cycle": {
- "title": "Cykl",
- "description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza."
}
},
"import": {
@@ -5301,35 +5307,18 @@
"practiceName": "Gabinet (opcjonalnie)",
"practiceNamePlaceholder": "np. dr Kowalski",
"includedData": "Uwzględnione dane",
- "groupVitals": "Parametry życiowe",
- "groupCardio": "Serce i kondycja",
- "groupActivity": "Aktywność i sen",
- "weight": "Waga",
- "bp": "Ciśnienie krwi",
- "pulse": "Tętno",
- "oxygenSaturation": "Saturacja tlenem",
- "bodyFat": "Tkanka tłuszczowa",
- "bodyComposition": "Skład ciała",
- "restingHeartRate": "Tętno spoczynkowe",
- "hrv": "Zmienność rytmu serca",
- "vo2max": "VO₂max",
- "steps": "Kroki",
- "distance": "Dystans",
- "sleep": "Sen",
- "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)",
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
- "error": "Eksport nie powiódł się ({code}).",
- "labs": "Wyniki badań",
- "allergies": "Alergie i nietolerancje",
- "familyHistory": "Wywiad rodzinny",
- "documentsLink": "Dokumenty z tego okresu"
+ "documentsLink": "Dokumenty z tego okresu",
+ "range30": "Ostatnie 30 dni",
+ "rangeCustom": "Własny okres",
+ "rangeCustomStart": "Od",
+ "rangeCustomEnd": "Do",
+ "errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
+ "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
+ "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.",
+ "errorEmptySelection": "Wybierz co najmniej jedną pozycję."
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
@@ -5380,7 +5369,11 @@
"pickerEmpty": "Nie znaleziono dokumentów.",
"pickerDone": "Gotowe",
"pickerSelectedCount": "Wybrano {count} z {max}",
- "pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów."
+ "pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów.",
+ "scopeTitle": "Co zawiera ten link",
+ "scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
+ "statusNeedsReselect": "Wybierz zawartość ponownie",
+ "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość."
},
"genderOther": "Inna",
"cycleTracking": {
@@ -6916,7 +6909,6 @@
"period": "Okres raportu: {start} – {end}",
"expires": "Ten link wygasa dnia {date}.",
"provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.",
- "vitals": "Parametry życiowe",
"labs": "Wyniki laboratoryjne",
"medications": "Leki i przestrzeganie zaleceń",
"bmi": "Wskaźnik masy ciała (BMI)",
@@ -6956,7 +6948,10 @@
"download": "Pobierz",
"attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).",
"exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP."
- }
+ },
+ "downloadPdf": "Pobierz jako PDF",
+ "downloadFhir": "Pobierz jako FHIR",
+ "bmiSection": "Wskaźnik masy ciała"
},
"cycle": {
"symptomCategory": {
@@ -8863,5 +8858,41 @@
}
}
}
+ },
+ "reportSelection": {
+ "groupIdentity": "Dane osobowe",
+ "groupVitals": "Parametry życiowe",
+ "groupCardio": "Serce i krążenie",
+ "groupBody": "Wymiary ciała",
+ "groupSleepRecovery": "Sen i regeneracja",
+ "groupActivity": "Aktywność",
+ "groupMobility": "Mobilność i chód",
+ "groupEnvironment": "Otoczenie",
+ "groupGlucose": "Poziom glukozy",
+ "groupLabs": "Wyniki badań",
+ "groupMedications": "Leki",
+ "groupHistory": "Choroby i alergie",
+ "groupSensitive": "Dane wrażliwe",
+ "leafPatientIdentity": "Imię, data urodzenia, płeć, wzrost",
+ "leafInsurance": "Ubezpieczyciel i numer ubezpieczenia",
+ "leafGlucosePanel": "Panel glukozy (czas w zakresie, GMI, zmienność)",
+ "leafLabResults": "Wyniki badań",
+ "leafMedicationList": "Lista leków",
+ "leafMedicationAdministrations": "Każda zarejestrowana dawka",
+ "leafMedicationCompliance": "Przestrzeganie zaleceń",
+ "leafGlp1Therapy": "Terapia GLP-1 (historia dawek, działania niepożądane)",
+ "leafAllergies": "Alergie i nietolerancje",
+ "leafIllnessEpisodes": "Epizody chorobowe",
+ "leafFamilyHistory": "Wywiad rodzinny",
+ "leafMood": "Nastrój",
+ "leafCycle": "Cykl menstruacyjny",
+ "templateStandard": "Standardowy raport lekarski",
+ "templateApplied": "{template} jest wstępnie wybrany. Sprawdź go przed wygenerowaniem.",
+ "groupCount": "{on}/{total}",
+ "groupDisclosure": "Pokaż pozycje w {group}",
+ "sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
+ "scopeNone": "Nic nie wybrano — dokument byłby pusty.",
+ "scopeCount": "{count} pozycji z {groups} obszarów",
+ "scopeSensitive": "w tym {names}"
}
}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 615a47f43..90643a7b7 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -110,6 +110,11 @@ export const GET = apiHandler(async () => {
// formatters and the primitive.
dateFormat: user.dateFormat ?? "AUTO",
lastReportPracticeName: user.lastReportPracticeName ?? null,
+ // The owner's saved report selection — the leaf inclusion list plus the
+ // format / range / charts choices the export panel restores. `null` when
+ // the account has never saved one, which is what makes the panel show the
+ // named template on the first run rather than a silent server default.
+ reportSelection: user.reportSelectionJson ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0078 commentary). Every Coach mount point on the client checks
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index 23ce8c6e4..dd02b9edd 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -22,9 +22,7 @@
*/
import { useState } from "react";
-import { useQuery } from "@tanstack/react-query";
import {
- CalendarHeart,
Download,
FileJson,
FileSpreadsheet,
@@ -44,9 +42,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { cn } from "@/lib/utils";
import { ImportPanel } from "@/components/settings/import-panel";
-import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
-import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { apiFetchRaw } from "@/lib/api/api-fetch";
type ExportFormat = "CSV" | "JSON" | "FHIR";
@@ -96,11 +93,6 @@ export function ExportSection() {
- {/* R30 — cycle export, gated on cycle tracking being enabled.
- The card mounts only for accounts with cycle data; it reuses
- the health-record FHIR export with the reproductive section
- opted in, so there is no separate backend path. */}
-
@@ -556,111 +548,3 @@ function FullBackupCard() {
}
// ─────────────────────────── Cycle export (gated) ───────────────────────────
-
-/**
- * R30 — cycle / reproductive-health export, GATED on cycle tracking
- * being enabled for the account. A standalone cycle export was removed
- * from the cycle settings in v1.15.4 (it lives in the full backup); this
- * surfaces an explicit, opt-in cycle export here for accounts that track
- * a cycle. It reuses the flagship `/api/export/health-record` route with
- * the reproductive section opted in and every other section off — no
- * separate backend path. The card does not render at all when cycle
- * tracking is off, so a non-cycle account never sees it.
- */
-function CycleExportCard() {
- const { t } = useTranslations();
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- // The ungated prefs read tells us whether cycle tracking is enabled
- // without tripping the cycle gate's 403.
- const prefsQuery = useQuery({
- queryKey: queryKeys.cyclePrefs(),
- queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
- return apiGet<{ cycleTrackingEnabled: boolean }>(
- "/api/auth/me/cycle-prefs",
- { credentials: "include" },
- );
- },
- staleTime: 5 * 60 * 1000,
- });
-
- // Gate: render nothing until we know cycle is on. A failed/absent read
- // keeps the card hidden (fail-closed) so it never appears spuriously.
- if (prefsQuery.data?.cycleTrackingEnabled !== true) return null;
-
- async function handleGenerate() {
- setBusy(true);
- setError(null);
- try {
- const res = await apiFetchRaw("/api/export/health-record", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({
- format: "fhir",
- sections: {
- vitals: {},
- cardioFitness: {},
- activity: {},
- glucose: false,
- medications: {},
- mood: false,
- bmi: false,
- cycle: true,
- },
- }),
- });
- if (!res.ok) {
- setError(t("settings.sections.export.downloadFailed"));
- return;
- }
- const blob = await res.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `healthlog-cycle-${new Date().toISOString().slice(0, 10)}.json`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- } catch {
- setError(t("settings.sections.export.downloadFailed"));
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
- {busy ? (
-
- ) : (
-
- )}
- {t("settings.sections.export.actions.download")}
-
- }
- >
- {error && (
-
- {error}
-
- )}
-
- );
-}
diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx
index 45d9389a2..84b6ac4d9 100644
--- a/src/components/settings/gesundheitsakte-section.tsx
+++ b/src/components/settings/gesundheitsakte-section.tsx
@@ -28,7 +28,7 @@ import { Share2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
+import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
import { SharingSection } from "@/components/settings/sharing-section";
export function GesundheitsakteSection() {
diff --git a/src/components/settings/health-record-export-controls.ts b/src/components/settings/health-record-export-controls.ts
deleted file mode 100644
index e3c8721ff..000000000
--- a/src/components/settings/health-record-export-controls.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 9b16222f1..000000000
--- a/src/components/settings/health-record-export-panel.tsx
+++ /dev/null
@@ -1,477 +0,0 @@
-"use client";
-
-/**
- * v1.7.0 — Settings → Export → Health Record export panel.
- *
- * One surface that drives `POST /api/export/health-record`: a format
- * toggle (PDF / FHIR / package) that drives the visible options below it
- * (per the "dropdown drives the form, no top/bottom split" preference),
- * a date-range picker, grouped data-section toggles, and the generate
- * action that streams the artefact as a download.
- *
- * Mood stays default-OFF everywhere (privacy). PDF-only fields (practice
- * name, charts) hide when the format is FHIR. The ePA-compat note shows
- * for the FHIR + package formats.
- *
- * v1.18.1 (D9) — the panel renders as a normal settings card (the v1.12
- * gradient/glow hero treatment was dropped: the gradient read too crass next
- * to the rest of the settings surface). The grouped data-section toggles live
- * behind a collapsible disclosure, collapsed by default, so the panel opens
- * compact instead of as a long always-expanded checklist.
- */
-
-import Link from "next/link";
-import { useId, useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import {
- ChevronDown,
- Download,
- FileText,
- FolderOpen,
- Loader2,
-} from "lucide-react";
-
-import { SettingsCard } from "@/components/settings/settings-card";
-import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { Input } from "@/components/ui/input";
-import { NativeSelect } from "@/components/ui/native-select";
-import { Switch } from "@/components/ui/switch";
-import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
-import { apiFetchRaw } from "@/lib/api/api-fetch";
-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"];
-
-export function HealthRecordExportPanel() {
- const { t, locale } = useTranslations();
- const { user } = useAuth();
- const queryClient = useQueryClient();
- const [format, setFormat] = useState("pdf");
- const [days, setDays] = useState(90);
- const [practiceName, setPracticeName] = useState("");
- const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState<
- string | null
- >(null);
- const [includeCharts, setIncludeCharts] = useState(true);
- const [includeAiSummary, setIncludeAiSummary] = useState(false);
- const [sections, setSections] = useState(DEFAULT_SECTIONS);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
- // The included-data checklist is a disclosure, collapsed by default —
- // the panel opens compact instead of as a long always-expanded list.
- const [includedDataOpen, setIncludedDataOpen] = useState(false);
- const includedDataPanelId = useId();
-
- // Seed the practice name from the last export once the auth payload lands.
- // `useAuth` resolves asynchronously, so a `useState` initializer would read
- // `undefined` and stick at empty. Store-the-id-alongside-the-state (the same
- // pattern the account form uses) syncs during render instead of in an
- // effect, and the per-user gate means a late re-resolve never clobbers a
- // field the user is already typing into.
- if (user && user.id !== practiceNameSeededUserId) {
- setPracticeNameSeededUserId(user.id);
- setPracticeName(user.lastReportPracticeName ?? "");
- }
-
- const isPdfLike = format === "pdf" || format === "package";
- const isFhirLike = format === "fhir" || format === "package";
-
- const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
- count: EXPORT_FORMATS.length,
- selectedIndex: EXPORT_FORMATS.indexOf(format),
- onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
- });
-
- function toggle(key: keyof SectionState) {
- setSections((prev) => ({ ...prev, [key]: !prev[key] }));
- }
-
- // 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. 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);
- return { ...prev, medList: next, compliance: next };
- });
- }
-
- async function handleGenerate() {
- setBusy(true);
- setError(null);
- try {
- const res = await apiFetchRaw("/api/export/health-record", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({
- format,
- // Carry the active in-app locale so the generated artefact matches
- // the UI language instead of falling back to the browser's
- // Accept-Language header on the server.
- locale,
- range: { days },
- practiceName: practiceName.trim() || undefined,
- includeCharts,
- includeAiSummary,
- sections: buildSelectionSections(sections),
- }),
- });
- if (!res.ok) {
- setError(`${res.status}`);
- return;
- }
- const blob = await res.blob();
- const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `healthlog-health-record-${new Date()
- .toISOString()
- .slice(0, 10)}.${ext}`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(url);
- // The route just persisted the practice name, so the cached `/me`
- // payload is stale. Refresh it, or a remount of this panel would seed
- // from the previous value.
- void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
-
-
-
- {/* Format + range share a row on desktop so the panel opens
- denser; on mobile they stack. */}
-
-
-
- {/* Date range */}
-
-
- setDays(Number(e.target.value))}
- >
-
-
-
-
- {/* Entry point into the document vault for the report period
- (navigation only — attaching documents to the PDF is a later
- phase). Year granularity is what the vault's URL filters
- offer; the current year covers the bulk of any range. Only
- rendered when the documents module is enabled. */}
- {user?.modules?.inboundDocuments ? (
-
-
- {t("settings.healthRecord.documentsLink")}
-
- ) : null}
-
-
-
- {/* Practice name — PDF only */}
- {isPdfLike && (
-
- )}
-
- {/* Section groups — collapsible, collapsed by default so the
- panel opens compact. The disclosure follows the inline
- aria-expanded/aria-controls pattern used elsewhere in the app
- (e.g. the insights recommendation cards). */}
-
-
- {/* The FHIR note and the generate action share the footer row so
- the panel ends compact instead of stacking a note above a
- right-aligned button. */}
-
-
- );
-}
-
-function ToggleRow({
- label,
- checked,
- onToggle,
-}: {
- label: string;
- checked: boolean;
- onToggle: () => void;
-}) {
- return (
-
- );
-}
diff --git a/src/components/settings/report-selection/group-row.tsx b/src/components/settings/report-selection/group-row.tsx
new file mode 100644
index 000000000..a20cf0df2
--- /dev/null
+++ b/src/components/settings/report-selection/group-row.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+/**
+ * One group row in the scope picker.
+ *
+ * The row is a `ListRow` carrying a group checkbox, the group name, a count
+ * chip, and a disclosure that opens the leaf grid. The group checkbox is one
+ * control for up to sixteen leaves, which is what makes the repeat visit two
+ * interactions; the chevron is the escape hatch and never sits on the fast
+ * path.
+ *
+ * `mixed` is a derived display state and never a click target: clicking a
+ * partly-selected group turns it fully on. Only leaves are ever stored.
+ */
+import { ChevronDown } from "lucide-react";
+
+import { Checkbox } from "@/components/ui/checkbox";
+import { ListRow } from "@/components/ui/list-row";
+import { TagChip } from "@/components/ui/tag-chip";
+import { cn } from "@/lib/utils";
+import type {
+ ReportGroupId,
+ ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+import type { GroupCheckState } from "@/lib/report-selection/panel-state";
+
+import { LeafGrid } from "./leaf-grid";
+
+export function GroupRow({
+ t,
+ id,
+ labelKey,
+ leaves,
+ selected,
+ checkState,
+ count,
+ open,
+ panelId,
+ onToggleGroup,
+ onToggleLeaf,
+ onToggleOpen,
+}: {
+ t: (key: string, vars?: Record) => string;
+ id: ReportGroupId;
+ labelKey: string;
+ leaves: readonly ReportLeafId[];
+ selected: ReadonlySet;
+ checkState: GroupCheckState;
+ count: { on: number; total: number };
+ open: boolean;
+ panelId: string;
+ onToggleGroup: () => void;
+ onToggleLeaf: (leaf: ReportLeafId) => void;
+ onToggleOpen: () => void;
+}) {
+ const label = t(labelKey);
+ return (
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/settings/report-selection/leaf-grid.tsx b/src/components/settings/report-selection/leaf-grid.tsx
new file mode 100644
index 000000000..40b2d9561
--- /dev/null
+++ b/src/components/settings/report-selection/leaf-grid.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+/**
+ * The per-leaf checkbox grid inside an expanded group.
+ *
+ * A dense read-out, not a field grid: `gap-2` on the enforced scale, two
+ * columns from the `sm` breakpoint. Each row clears the tap floor and carries
+ * a stable test id so an end-to-end assertion binds to the leaf, not to text
+ * that a breakpoint may hide.
+ */
+import { Checkbox } from "@/components/ui/checkbox";
+import { MEASUREMENT_TYPE_ICONS } from "@/components/measurements/measurement-list-meta";
+import {
+ leafLabelKey,
+ type ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+
+export function LeafGrid({
+ t,
+ leaves,
+ selected,
+ onToggle,
+}: {
+ t: (key: string, vars?: Record) => string;
+ leaves: readonly ReportLeafId[];
+ selected: ReadonlySet;
+ onToggle: (leaf: ReportLeafId) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/settings/report-selection/report-scope-picker.tsx b/src/components/settings/report-selection/report-scope-picker.tsx
new file mode 100644
index 000000000..c72622686
--- /dev/null
+++ b/src/components/settings/report-selection/report-scope-picker.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+/**
+ * The one scope picker. Both surfaces that choose what a report contains — the
+ * export panel and the share-link create form — mount this exact component, so
+ * two selection UIs cannot drift apart.
+ *
+ * Twelve group rows plus a fenced tier below a separator. The fenced tier has
+ * NO group checkbox: there is no control anywhere in this component that can
+ * turn on more than one sensitive leaf at a time, and there is no select-all.
+ * The fence uses no colour to mark itself — a separator, a heading, and
+ * per-leaf controls. A red tint would be decoration; status colour is reserved
+ * for meaning.
+ *
+ * The component is controlled: it owns no selection state, only which rows are
+ * expanded and what each group looked like before it was switched off.
+ */
+import { useId, useState } from "react";
+
+import { Separator } from "@/components/ui/separator";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ REPORT_GROUPS,
+ SENSITIVE_GROUP_ID,
+ type ReportGroupId,
+ type ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+import {
+ groupCheckState,
+ groupCount,
+ toggleGroup,
+ toggleLeaf,
+} from "@/lib/report-selection/panel-state";
+
+import { GroupRow } from "./group-row";
+import { LeafGrid } from "./leaf-grid";
+
+export function ReportScopePicker({
+ selected,
+ onChange,
+ hiddenLeaves,
+}: {
+ selected: ReadonlySet;
+ onChange: (next: Set) => void;
+ /**
+ * Leaves this surface must not offer at all. The share-link form passes the
+ * insurance leaf: the server refuses it with a 422, and a control that
+ * cannot be honoured has no business rendering.
+ */
+ hiddenLeaves?: readonly ReportLeafId[];
+}) {
+ const { t } = useTranslations();
+ const idPrefix = useId();
+ const [openGroups, setOpenGroups] = useState>(
+ new Set(),
+ );
+ // What each group looked like before it was switched off, so switching it
+ // back on inside one session restores the pattern instead of flooding it.
+ const [remembered, setRemembered] = useState>(
+ new Set(),
+ );
+
+ const hidden = new Set(hiddenLeaves ?? []);
+ const groups = REPORT_GROUPS.map((group) => ({
+ ...group,
+ leaves: group.leaves.filter((leaf) => !hidden.has(leaf)),
+ })).filter((group) => group.leaves.length > 0);
+
+ const plainGroups = groups.filter((g) => g.id !== SENSITIVE_GROUP_ID);
+ const sensitiveGroup = groups.find((g) => g.id === SENSITIVE_GROUP_ID);
+
+ const handleLeaf = (leaf: ReportLeafId) => {
+ setRemembered(new Set(selected));
+ onChange(toggleLeaf(selected, leaf));
+ };
+
+ const handleGroup = (group: ReportGroupId) => {
+ setRemembered(new Set(selected));
+ onChange(toggleGroup(selected, group, remembered));
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/settings/report-selection/scope-summary.tsx b/src/components/settings/report-selection/scope-summary.tsx
new file mode 100644
index 000000000..7887ffa71
--- /dev/null
+++ b/src/components/settings/report-selection/scope-summary.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+/**
+ * The one line that says what the artefact will contain.
+ *
+ * This is the consent check: reading it IS the review. Fenced inclusions are
+ * restated by name rather than folded into the count, because a number cannot
+ * be checked at a glance and "incl. Mood, PHQ-9" can.
+ *
+ * Meta tier — one sentence about the form beside it, not content.
+ */
+import { scopeSummary } from "@/lib/report-selection/panel-state";
+import type { ReportLeafId } from "@/lib/report-selection/catalogue";
+
+export function ScopeSummary({
+ t,
+ selected,
+}: {
+ t: (key: string, vars?: Record) => string;
+ selected: ReadonlySet;
+}) {
+ const summary = scopeSummary(selected);
+ if (summary.total === 0) {
+ return (
+
+ )}
+
+
+
+ {/* The FHIR note and the generate action share the footer row. */}
+
+
+
+ {isFhirLike ? (
+
+ {t("settings.healthRecord.fhirNote")}
+
+ ) : null}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx
index 9f14a86a3..69cdbab55 100644
--- a/src/components/settings/share-link-create-form.tsx
+++ b/src/components/settings/share-link-create-form.tsx
@@ -32,6 +32,11 @@ import {
ShareDocumentPicker,
type PickedDocument,
} from "@/components/settings/share-document-picker";
+import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker";
+import { ScopeSummary } from "@/components/settings/report-selection/scope-summary";
+import type { ReportLeafId } from "@/lib/report-selection/catalogue";
+import { orderLeaves } from "@/lib/report-selection/selection";
+import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
@@ -58,6 +63,13 @@ export interface ShareLinkSummary {
protected: boolean;
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
documentCount: number;
+ /**
+ * Whether this link's frozen scope predates the selection model. Such links
+ * were revoked on upgrade — their scope was "everything except mood and
+ * cycle", which nobody chose — and the sharing list says so, so the owner
+ * re-mints rather than wondering why a working link stopped.
+ */
+ needsReselection: boolean;
expiresAt: string;
createdAt: string;
revokedAt: string | null;
@@ -72,6 +84,14 @@ export interface ShareLinkSummary {
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
*/
export interface ShareLinkCreated extends ShareLinkSummary {
+ /**
+ * RETIRED, response-only. They configured a FHIR face that was never built;
+ * the request refuses them and the columns are gone. They stay here because
+ * the native client was given this shape and a missing non-optional key
+ * fails a whole Swift decode — see #70 in the native repository.
+ */
+ allowFhirApi: boolean;
+ resourceTypes: string[];
token: string;
passphrase: string;
shareUrl: string;
@@ -87,17 +107,25 @@ export interface ShareLinkCreatePayload {
label: string;
rangeStart: string;
rangeEnd: null;
+ selection?: { v: 2; leaves: readonly string[] };
documentIds?: string[];
documentOnly?: boolean;
expiresAt: string;
}
+/**
+ * The one leaf a share link may never carry — mirrored from the server, which
+ * refuses it with a 422. The picker hides it rather than rendering a control
+ * that cannot be honoured.
+ */
+const SHARE_HIDDEN_LEAVES: readonly ReportLeafId[] = ["INSURANCE"];
+
/**
* Build the create payload from the resolved form state. Extracted as a pure
* 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 (no `sections`) plus `documentOnly: true`,
+ * share posts NO report scope at all 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
@@ -107,6 +135,7 @@ export function buildShareLinkCreatePayload(input: {
label: string;
rangeDays: number;
expiryDays: number;
+ leaves: readonly ReportLeafId[];
documentIds: string[];
documentOnly: boolean;
}): ShareLinkCreatePayload {
@@ -127,6 +156,7 @@ export function buildShareLinkCreatePayload(input: {
}
return {
...base,
+ selection: { v: 2, leaves: input.leaves },
// 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.
@@ -163,6 +193,18 @@ export function ShareLinkCreateForm({
const [selectedDocs, setSelectedDocs] = useState(
initialDocuments ?? [],
);
+ // The named standard template is the starting point for a record share, the
+ // same one the export panel opens with, minus the leaf a link may not carry.
+ const [selectedLeaves, setSelectedLeaves] = useState<
+ ReadonlySet
+ >(
+ () =>
+ new Set(
+ STANDARD_TEMPLATE_LEAVES.filter(
+ (leaf) => !SHARE_HIDDEN_LEAVES.includes(leaf),
+ ),
+ ),
+ );
const [pickerOpen, setPickerOpen] = useState(false);
const [created, setCreated] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState(null);
@@ -212,6 +254,7 @@ export function ShareLinkCreateForm({
label,
rangeDays,
expiryDays,
+ leaves: orderLeaves(selectedLeaves),
documentIds: selectedDocs.map((d) => d.id),
documentOnly,
}),
@@ -310,6 +353,26 @@ export function ShareLinkCreateForm({
+ {/* ── What the link contains ───────────────────────────────── */}
+ {!documentOnly ? (
+
@@ -384,7 +447,11 @@ export function ShareLinkCreateForm({
+ {/* A link whose frozen scope predates the selection model.
+ It served a scope nobody chose, so it was closed; the
+ owner re-mints above with the label, window and expiry
+ still in front of them. */}
+ {link.needsReselection ? (
+
+ {t("settings.sharing.reselectReason")}
+
+ ) : null}
{t("settings.sharing.accessCount")}:
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
index 9ff06273e..bde433d61 100644
--- a/src/components/ui/checkbox.tsx
+++ b/src/components/ui/checkbox.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
-import { Check } from "lucide-react";
+import { Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -16,6 +16,12 @@ import { cn } from "@/lib/utils";
* Mirrors the v1.4.27 shadcn `` recipe: a Root that draws
* the border + checked-state background, an Indicator slot that
* shows the lucide `` when the box is checked.
+ *
+ * The Indicator also draws a `` for the indeterminate state, which
+ * Radix's Root accepts natively via `checked="indeterminate"`. A group row
+ * whose leaves are partly selected is a real third state and needs to look
+ * like one — drawing it as unchecked would say "none of this is included",
+ * which on this surface is a false statement about what leaves the instance.
*/
function Checkbox({
className,
@@ -34,6 +40,7 @@ function Checkbox({
// blanket neighbouring controls in dense table rows.
"peer border-border/70 relative size-4 shrink-0 rounded border before:absolute before:inset-[-8px] before:content-['']",
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
+ "data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-background",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"transition-colors",
@@ -45,7 +52,11 @@ function Checkbox({
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
-
+ {props.checked === "indeterminate" ? (
+
+ ) : (
+
+ )}
);
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index af8208231..f0da3add3 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -123,6 +123,13 @@ export interface AuthUser {
* user has never set one, or deliberately cleared the line.
*/
lastReportPracticeName: string | null;
+ /**
+ * The saved report selection blob (`{ v, leaves, format, rangeDays,
+ * includeCharts }`), or `null` when the account has never saved one. Parsed
+ * through `parseSavedProfile` at the one place that reads it, so a drifted
+ * shape resolves to "never saved" rather than to a partial scope.
+ */
+ reportSelection: unknown;
/**
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
* + the per-user opt-in. The cycle nav entry + page hide when false; every
diff --git a/src/lib/report-selection/panel-state.ts b/src/lib/report-selection/panel-state.ts
new file mode 100644
index 000000000..2ef8a734e
--- /dev/null
+++ b/src/lib/report-selection/panel-state.ts
@@ -0,0 +1,124 @@
+/**
+ * The pure state of the report scope picker.
+ *
+ * Kept out of the client component so the control shape is testable without a
+ * DOM, and so the structural guard can import a lib module rather than reach
+ * into a component. The panel owns layout and interaction; this owns "which
+ * controls exist, what each one means, and what the scope line says".
+ *
+ * Only leaves are ever stored. A group's state is DERIVED from its leaves, so
+ * a group and its leaves cannot disagree — there is no second source of truth
+ * to reconcile.
+ */
+import {
+ REPORT_GROUPS,
+ SENSITIVE_GROUP_ID,
+ leafLabelKey,
+ type ReportGroupId,
+ type ReportLeafId,
+} from "./catalogue";
+
+/** The panel's own state: the set of chosen leaf ids. */
+export type PanelSelection = ReadonlySet;
+
+/** A group checkbox is on, off, or partly on. `mixed` is never a click target. */
+export type GroupCheckState = "all" | "none" | "mixed";
+
+export function groupCheckState(
+ group: ReportGroupId,
+ selected: PanelSelection,
+): GroupCheckState {
+ const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
+ if (leaves.length === 0) return "none";
+ const on = leaves.filter((leaf) => selected.has(leaf)).length;
+ if (on === 0) return "none";
+ if (on === leaves.length) return "all";
+ return "mixed";
+}
+
+/** How many of a group's leaves are chosen, and how many there are. */
+export function groupCount(
+ group: ReportGroupId,
+ selected: PanelSelection,
+): { on: number; total: number } {
+ const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
+ return {
+ on: leaves.filter((leaf) => selected.has(leaf)).length,
+ total: leaves.length,
+ };
+}
+
+/** Toggle one leaf. */
+export function toggleLeaf(
+ selected: PanelSelection,
+ leaf: ReportLeafId,
+): Set {
+ const next = new Set(selected);
+ if (next.has(leaf)) next.delete(leaf);
+ else next.add(leaf);
+ return next;
+}
+
+/**
+ * Toggle a whole group.
+ *
+ * `mixed` and `none` both go to all-on; `all` goes to all-off. Turning a group
+ * off and back on within one session restores the previous leaf pattern via
+ * `remembered`, so the escape hatch does not cost the user their earlier
+ * refinements. Across sessions only leaves persist and the group state is
+ * re-derived.
+ *
+ * The fenced tier has no group control at all — `REPORT_GROUPS` still lists
+ * it, but the panel renders no checkbox for it, and this function refuses it
+ * so no future caller can route around that.
+ */
+export function toggleGroup(
+ selected: PanelSelection,
+ group: ReportGroupId,
+ remembered?: PanelSelection,
+): Set {
+ if (group === SENSITIVE_GROUP_ID) return new Set(selected);
+ const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
+ const next = new Set(selected);
+ if (groupCheckState(group, selected) === "all") {
+ for (const leaf of leaves) next.delete(leaf);
+ return next;
+ }
+ const restore = remembered
+ ? leaves.filter((leaf) => remembered.has(leaf))
+ : [];
+ for (const leaf of restore.length > 0 ? restore : leaves) next.add(leaf);
+ return next;
+}
+
+/**
+ * The scope line under the Generate button, as the parts a component renders.
+ *
+ * Fenced inclusions are always restated BY NAME, never folded into the count.
+ * A number cannot be checked at a glance; "incl. Mood, PHQ-9" can, and the
+ * reading is the consent check.
+ */
+export interface ScopeSummary {
+ /** How many leaves are chosen in total. */
+ total: number;
+ /** How many groups contribute at least one leaf. */
+ groups: number;
+ /** i18n label keys for the chosen fenced leaves, in catalogue order. */
+ sensitiveLabelKeys: string[];
+}
+
+export function scopeSummary(selected: PanelSelection): ScopeSummary {
+ let total = 0;
+ let groups = 0;
+ const sensitiveLabelKeys: string[] = [];
+ for (const group of REPORT_GROUPS) {
+ const on = group.leaves.filter((leaf) => selected.has(leaf));
+ if (on.length === 0) continue;
+ groups += 1;
+ total += on.length;
+ if (group.id === SENSITIVE_GROUP_ID) {
+ sensitiveLabelKeys.push(...on.map(leafLabelKey));
+ }
+ }
+ return { total, groups, sensitiveLabelKeys };
+}
From f42934487d977cb4263637649d772cbd625601e9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 18:30:38 +0200
Subject: [PATCH 05/17] test(export): prove the scope holds at the artefact,
not just at the switch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five layers, because the defect had five places to hide.
The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.
The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.
The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.
The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.
The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.
Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.
The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.
Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.
The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
---
docs/api/openapi.yaml | 423 ++++--
...doctor-report-control-gating-guard.test.ts | 250 ++--
.../report-selection-egress-guard.test.ts | 83 ++
.../health-record/__tests__/route.test.ts | 295 +++--
src/app/api/fhir/Coverage/[id]/route.ts | 50 +
src/app/api/fhir/Coverage/route.ts | 55 +
.../MedicationAdministration/[id]/route.ts | 53 +
.../fhir/MedicationAdministration/route.ts | 38 +-
.../fhir/MedicationStatement/[id]/route.ts | 53 +
src/app/api/fhir/MedicationStatement/route.ts | 33 +-
src/app/api/fhir/Observation/[id]/route.ts | 48 +
src/app/api/fhir/Observation/route.ts | 37 +-
.../fhir/{ => Patient}/$everything/route.ts | 56 +-
src/app/api/fhir/Patient/[id]/route.ts | 48 +
src/app/api/fhir/Patient/route.ts | 38 +-
.../capability-and-everything.test.ts | 360 ++++++
.../api/fhir/__tests__/module-gate.test.ts | 84 +-
src/app/api/fhir/__tests__/route.test.ts | 1 +
src/app/api/fhir/metadata/route.ts | 77 +-
.../meta/capabilities/__tests__/route.test.ts | 24 +-
.../api/share-links/__tests__/route.test.ts | 132 +-
.../__tests__/clinician-view.test.tsx | 15 +-
.../__tests__/disable-coach-card.test.tsx | 1 +
...ealth-record-export-practice-name.test.tsx | 97 --
.../__tests__/modules-section.test.tsx | 1 +
.../__tests__/report-selection-panel.test.tsx | 234 ++++
.../__tests__/share-link-create-form.test.tsx | 11 +-
.../__tests__/time-format-select.test.tsx | 1 +
.../__tests__/unit-preference-select.test.tsx | 1 +
.../report-selection/selection-panel.tsx | 22 +-
.../doctor-report-module-backstop.test.ts | 13 +-
.../__tests__/doctor-report-pdf-core.test.ts | 53 +-
.../measurement-type-enum-coverage.test.ts | 191 +--
.../__tests__/share-view-data.test.ts | 167 +--
.../clinician-share/resolve-share-token.ts | 13 +-
.../__tests__/leaf-gating-sweep.test.ts | 403 ++++++
.../__tests__/coach-user-disable.test.tsx | 1 +
src/lib/fhir/__tests__/build-bundle.test.ts | 70 +-
src/lib/fhir/__tests__/conformance.test.ts | 516 ++++++++
.../__tests__/rest-module-backstop.test.ts | 8 +
src/lib/fhir/build-bundle.ts | 261 +++-
src/lib/fhir/resources.ts | 1143 -----------------
src/lib/fhir/resources/common.ts | 137 ++
src/lib/fhir/resources/conditions.ts | 124 ++
src/lib/fhir/resources/coverage.ts | 52 +
src/lib/fhir/resources/cycle.ts | 146 +++
src/lib/fhir/resources/glucose.ts | 171 +++
src/lib/fhir/resources/index.ts | 38 +
src/lib/fhir/resources/labs.ts | 113 ++
src/lib/fhir/resources/medications.ts | 224 ++++
src/lib/fhir/resources/observation.ts | 283 ++++
src/lib/fhir/resources/patient.ts | 65 +
src/lib/fhir/rest.ts | 99 +-
src/lib/fhir/types.ts | 55 +-
src/lib/fhir/ucum.ts | 92 ++
src/lib/mcp/__tests__/prompts.test.ts | 3 +
.../mcp/__tests__/resource-templates.test.ts | 6 +
src/lib/mcp/__tests__/resources.test.ts | 3 +
src/lib/openapi/routes/health-record.ts | 195 ++-
.../__tests__/catalogue-guard.test.ts | 114 ++
.../__tests__/panel-state.test.ts | 89 ++
.../__tests__/selection.test.ts | 99 ++
src/lib/report-selection/saved-profile.ts | 6 +-
.../fixtures/expected-measurement-loinc.json | 12 +
src/lib/signals/registry.ts | 23 +-
.../__tests__/doctor-report-prefs.test.ts | 126 --
.../__tests__/health-record-export.test.ts | 182 ++-
.../doctor-report-dense-data.test.ts | 28 +-
.../doctor-report-sections.test.ts | 149 ++-
.../medication-administrations.test.ts | 11 +-
70 files changed, 5646 insertions(+), 2459 deletions(-)
create mode 100644 src/__tests__/report-selection-egress-guard.test.ts
create mode 100644 src/app/api/fhir/Coverage/[id]/route.ts
create mode 100644 src/app/api/fhir/Coverage/route.ts
create mode 100644 src/app/api/fhir/MedicationAdministration/[id]/route.ts
create mode 100644 src/app/api/fhir/MedicationStatement/[id]/route.ts
create mode 100644 src/app/api/fhir/Observation/[id]/route.ts
rename src/app/api/fhir/{ => Patient}/$everything/route.ts (58%)
create mode 100644 src/app/api/fhir/Patient/[id]/route.ts
create mode 100644 src/app/api/fhir/__tests__/capability-and-everything.test.ts
delete mode 100644 src/components/settings/__tests__/health-record-export-practice-name.test.tsx
create mode 100644 src/components/settings/__tests__/report-selection-panel.test.tsx
create mode 100644 src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts
create mode 100644 src/lib/fhir/__tests__/conformance.test.ts
delete mode 100644 src/lib/fhir/resources.ts
create mode 100644 src/lib/fhir/resources/common.ts
create mode 100644 src/lib/fhir/resources/conditions.ts
create mode 100644 src/lib/fhir/resources/coverage.ts
create mode 100644 src/lib/fhir/resources/cycle.ts
create mode 100644 src/lib/fhir/resources/glucose.ts
create mode 100644 src/lib/fhir/resources/index.ts
create mode 100644 src/lib/fhir/resources/labs.ts
create mode 100644 src/lib/fhir/resources/medications.ts
create mode 100644 src/lib/fhir/resources/observation.ts
create mode 100644 src/lib/fhir/resources/patient.ts
create mode 100644 src/lib/fhir/ucum.ts
create mode 100644 src/lib/report-selection/__tests__/catalogue-guard.test.ts
create mode 100644 src/lib/report-selection/__tests__/panel-state.test.ts
create mode 100644 src/lib/report-selection/__tests__/selection.test.ts
delete mode 100644 src/lib/validations/__tests__/doctor-report-prefs.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 2889c47f5..5f2d5e945 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -871,10 +871,12 @@ paths:
tags:
- Export
summary: Generate a health-record export (PDF / FHIR / package)
- description: "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` →
- application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF
- + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation:
- unknown keys 422."
+ description: "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` →
+ application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README).
+ `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and
+ there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape
+ is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or
+ Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422."
requestBody:
required: true
content:
@@ -904,65 +906,23 @@ paths:
minimum: 1
maximum: 365
additionalProperties: false
- sections:
+ selection:
type: object
properties:
- vitals:
- type: object
- properties:
- weight:
- type: boolean
- bp:
- type: boolean
- pulse:
- type: boolean
- oxygenSaturation:
- type: boolean
- bodyFat:
- type: boolean
- bodyComposition:
- type: boolean
- cardioFitness:
- type: object
- properties:
- restingHeartRate:
- type: boolean
- hrv:
- type: boolean
- vo2max:
- type: boolean
- activity:
- type: object
- properties:
- steps:
- type: boolean
- distance:
- type: boolean
- sleep:
- type: boolean
- glucose:
- type: boolean
- medications:
- type: object
- properties:
- list:
- type: boolean
- compliance:
- type: boolean
- mood:
- type: boolean
- bmi:
- type: boolean
- cycle:
- type: boolean
- labs:
- type: boolean
- allergies:
- type: boolean
- familyHistory:
- type: boolean
- mentalHealthScreeners:
- type: boolean
+ v:
+ type: number
+ const: 2
+ leaves:
+ maxItems: 90
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 64
+ required:
+ - v
+ - leaves
+ additionalProperties: false
locale:
type: string
enum:
@@ -977,12 +937,11 @@ paths:
maxLength: 120
includeCharts:
type: boolean
- includeAiSummary:
- type: boolean
germanAtc:
type: boolean
required:
- format
+ - selection
additionalProperties: false
responses:
"200":
@@ -1010,14 +969,13 @@ paths:
- Export
summary: Create a clinician share link (v1.11.0)
description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY
- ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with
- `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase
- cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen
- write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's
- own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports
- `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt`
- is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h).
- Strict: unknown keys 422."
+ ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL
+ fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen
+ write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the
+ `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional
+ `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a
+ documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at
+ 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422."
requestBody:
required: true
content:
@@ -1039,65 +997,23 @@ paths:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
- sections:
+ selection:
type: object
properties:
- vitals:
- type: object
- properties:
- weight:
- type: boolean
- bp:
- type: boolean
- pulse:
- type: boolean
- oxygenSaturation:
- type: boolean
- bodyFat:
- type: boolean
- bodyComposition:
- type: boolean
- cardioFitness:
- type: object
- properties:
- restingHeartRate:
- type: boolean
- hrv:
- type: boolean
- vo2max:
- type: boolean
- activity:
- type: object
- properties:
- steps:
- type: boolean
- distance:
- type: boolean
- sleep:
- type: boolean
- glucose:
- type: boolean
- medications:
- type: object
- properties:
- list:
- type: boolean
- compliance:
- type: boolean
- mood:
- type: boolean
- bmi:
- type: boolean
- cycle:
- type: boolean
- labs:
- type: boolean
- allergies:
- type: boolean
- familyHistory:
- type: boolean
- mentalHealthScreeners:
- type: boolean
+ v:
+ type: number
+ const: 2
+ leaves:
+ maxItems: 90
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 64
+ required:
+ - v
+ - leaves
+ additionalProperties: false
documentIds:
maxItems: 50
type: array
@@ -1272,8 +1188,9 @@ paths:
- FHIR
summary: FHIR R4 CapabilityStatement (v1.11.0)
description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient,
- Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the
- `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass)."
+ Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type`
+ interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised
+ interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)."
responses:
"200":
description: CapabilityStatement (application/fhir+json).
@@ -1393,14 +1310,204 @@ paths:
"401": *a1
"422": *a3
"429": *a4
- /api/fhir/$everything:
+ /api/fhir/Coverage:
+ get:
+ tags:
+ - FHIR
+ summary: FHIR R4 Coverage search
+ description: "Read-only `searchset` Bundle of the caller's own Coverage resource, when an insurer is recorded and the
+ insurance leaf is in the saved selection. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) /
+ `_offset`. `userId` is narrowed from auth."
+ parameters:
+ - in: query
+ name: _count
+ schema:
+ type: number
+ - in: query
+ name: _offset
+ schema:
+ type: number
+ responses:
+ "200":
+ description: searchset Bundle (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/fhir/Patient/{id}:
+ get:
+ tags:
+ - FHIR
+ summary: FHIR R4 Patient read
+ description: "Read-only `Patient` by id, out of the caller's own record. An id belonging to someone else is
+ indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
+ scope; `userId` is narrowed from auth."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The resource (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "401": *a1
+ "404":
+ description: OperationOutcome (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "422": *a3
+ "429": *a4
+ /api/fhir/Coverage/{id}:
+ get:
+ tags:
+ - FHIR
+ summary: FHIR R4 Coverage read
+ description: "Read-only `Coverage` by id, out of the caller's own record. An id belonging to someone else is
+ indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
+ scope; `userId` is narrowed from auth."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The resource (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "401": *a1
+ "404":
+ description: OperationOutcome (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "422": *a3
+ "429": *a4
+ /api/fhir/Observation/{id}:
+ get:
+ tags:
+ - FHIR
+ summary: FHIR R4 Observation read
+ description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is
+ indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
+ scope; `userId` is narrowed from auth."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The resource (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "401": *a1
+ "404":
+ description: OperationOutcome (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "422": *a3
+ "429": *a4
+ /api/fhir/MedicationStatement/{id}:
+ get:
+ tags:
+ - FHIR
+ summary: FHIR R4 MedicationStatement read
+ description: "Read-only `MedicationStatement` by id, out of the caller's own record. An id belonging to someone else is
+ indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read`
+ scope; `userId` is narrowed from auth."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The resource (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "401": *a1
+ "404":
+ description: OperationOutcome (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "422": *a3
+ "429": *a4
+ /api/fhir/MedicationAdministration/{id}:
+ get:
+ tags:
+ - FHIR
+ summary: FHIR R4 MedicationAdministration read
+ description: "Read-only `MedicationAdministration` by id, out of the caller's own record. An id belonging to someone
+ else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth:
+ `fhir:read` scope; `userId` is narrowed from auth."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The resource (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "401": *a1
+ "404":
+ description: OperationOutcome (application/fhir+json).
+ content:
+ application/fhir+json:
+ schema:
+ type: string
+ format: binary
+ "422": *a3
+ "429": *a4
+ /api/fhir/Patient/$everything:
get:
tags:
- FHIR
summary: FHIR R4 $everything (v1.11.0)
- description: "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage,
- Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read`
- scope. Offset paging via `_count` (≤200) / `_offset`."
+ description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device,
+ Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations,
+ Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one
+ `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing.
+ Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`."
parameters:
- in: query
name: _count
@@ -15670,23 +15777,35 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Maximum lifetime of a share link, in days. No never-expiring share.
- resourceTypes:
+ reportDownload:
type: array
items:
type: string
- description: FHIR resource types a share link may be scoped to serve.
- sections:
+ description: Machine formats a share link serves as a download under its token, behind the same unlock as the page.
+ selectionVersion:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: Version of the leaf-selection grammar this build speaks.
+ groups:
type: array
items:
type: string
- description: Scopeable report sections a share link may toggle.
+ description: Selection group ids, in presentation order.
+ leaves:
+ type: array
+ items:
+ type: string
+ description: Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion.
required:
- supported
- maxDays
- - resourceTypes
- - sections
+ - reportDownload
+ - selectionVersion
+ - groups
+ - leaves
additionalProperties: false
- description: Clinician share-link surface descriptor (v1.11).
+ description: Clinician share-link surface descriptor.
required:
- apiContractVersion
- derivedMetricIds
@@ -15719,6 +15838,14 @@ components:
type: boolean
documentCount:
type: number
+ needsReselection:
+ type: boolean
+ allowFhirApi:
+ type: boolean
+ resourceTypes:
+ type: array
+ items:
+ type: string
expiresAt:
type: string
createdAt:
@@ -15755,6 +15882,9 @@ components:
- rangeEnd
- protected
- documentCount
+ - needsReselection
+ - allowFhirApi
+ - resourceTypes
- expiresAt
- createdAt
- revokedAt
@@ -15803,6 +15933,14 @@ components:
type: boolean
documentCount:
type: number
+ needsReselection:
+ type: boolean
+ allowFhirApi:
+ type: boolean
+ resourceTypes:
+ type: array
+ items:
+ type: string
expiresAt:
type: string
createdAt:
@@ -15826,6 +15964,9 @@ components:
- rangeEnd
- protected
- documentCount
+ - needsReselection
+ - allowFhirApi
+ - resourceTypes
- expiresAt
- createdAt
- revokedAt
diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts
index b7c476dff..0aa41fec0 100644
--- a/src/__tests__/doctor-report-control-gating-guard.test.ts
+++ b/src/__tests__/doctor-report-control-gating-guard.test.ts
@@ -1,187 +1,149 @@
/**
- * Structural guard: a rendered export control with no gating path fails the
- * build.
+ * Structural guard: a rendered scope control with no gating path fails the
+ * build, and a gating path with no control fails it too.
*
- * The health-record export panel shipped twenty data-selection switches, of
- * which eight changed nothing at all. `toDoctorReportPrefs` folded the grouped
- * wire shape down to twelve flat flags and dropped the rest on the floor, so a
- * user who switched off SpO₂, body fat, body composition, resting heart rate,
- * HRV, VO₂max, steps or distance exported them anyway — in the PDF, in the FHIR
- * bundle, in the package, in a clinician share link and over MCP. Nothing
- * failed; the control simply lied, and it lied for several releases because no
- * test connected "the panel renders this" to "the export honours this".
+ * The export panel once shipped twenty data switches, eight of which changed
+ * nothing at all — the wire fold read twelve of the keys it accepted and
+ * dropped the rest on the floor. Nothing failed; the control simply lied, and
+ * it lied for several releases because no test connected "the panel renders
+ * this" to "the export honours this".
*
- * This file is that connection, in three links that each break independently:
+ * The selection model removed the fold, so the shape that defect lived in is
+ * gone. This file is the same connection retargeted at the leaf catalogue,
+ * which is now the one thing the picker, the aggregator, the PDF and the share
+ * view all read:
*
- * 1. Every control the panel holds state for survives the wire schema. A key
- * the schema strips can never be honoured, however carefully the fold is
- * written.
- * 2. Flipping exactly one control changes the resolved `DoctorReportPrefs`
- * the aggregator consumes. This is the assertion that would have caught
- * all eight dead switches on the day they were added.
- * 3. Every flag in the resolved shape is read downstream — either as a
- * per-measurement-type entry in the selection gates or by name in the
- * aggregator body. A flag nothing reads is the same defect one layer down.
- *
- * It also checks the panel actually renders every control it declares, so the
- * split between the control shape and its rendering cannot quietly leave a
- * declared control with no row (or a row with no gate).
+ * 1. Every leaf the catalogue declares is reachable in the picker — the
+ * picker renders from the catalogue, so the check is that the catalogue
+ * places each leaf in exactly one group and the picker renders each group.
+ * 2. Flipping exactly one leaf changes the resolved selection. This is the
+ * assertion that would have caught all eight dead switches.
+ * 3. Every leaf is honoured somewhere downstream: as a measurement type the
+ * gate strips, or by name in the aggregator's own body.
*
* What it does NOT prove: that the excluded data is actually absent from a
- * rendered artefact. That is a behavioural property and it is covered where the
- * artefacts are built — `src/app/api/export/health-record/__tests__/route.test.ts`
- * asserts the PDF, the FHIR bundle and the zip for the same selections. This
- * guard is the cheap structural half that runs on every commit and cannot be
- * satisfied by writing a control that looks plausible.
+ * rendered artefact. That is behavioural and is covered where the artefacts are
+ * built — `src/app/api/export/health-record/__tests__/route.test.ts` opens the
+ * real PDF, the real bundle and the real zip. This guard is the cheap
+ * structural half that cannot be satisfied by writing a plausible-looking
+ * control.
+ *
+ * Mutation checks are recorded per assertion below.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
- exportSectionsSchema,
- toDoctorReportPrefs,
-} from "@/lib/validations/health-record-export";
-import {
- DEFAULT_DOCTOR_REPORT_PREFS,
- type DoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
-import {
- DEFAULT_SECTIONS,
- buildSelectionSections,
- type SectionState,
-} from "@/components/settings/health-record-export-controls";
-import { MEASUREMENT_TYPE_SECTION } from "@/lib/doctor-report-selection";
+ ALL_LEAF_IDS,
+ MEASUREMENT_LEAF_GROUP,
+ REPORT_GROUPS,
+ STRUCTURED_LEAF_GROUP,
+ isStructuredLeafId,
+ leafGroup,
+ type ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
const REPO_ROOT = join(__dirname, "..", "..");
-const PANEL_PATH = join(
+const COLLECT_PATH = join(
+ REPO_ROOT,
+ "src",
+ "lib",
+ "doctor-report",
+ "collect.ts",
+);
+const PICKER_PATH = join(
REPO_ROOT,
"src",
"components",
"settings",
- "health-record-export-panel.tsx",
+ "report-selection",
+ "report-scope-picker.tsx",
);
-const AGGREGATOR_PATH = join(REPO_ROOT, "src", "lib", "doctor-report-data.ts");
-const panelSource = readFileSync(PANEL_PATH, "utf8");
-const aggregatorSource = readFileSync(AGGREGATOR_PATH, "utf8");
+const collectSource = readFileSync(COLLECT_PATH, "utf8");
+const pickerSource = readFileSync(PICKER_PATH, "utf8");
-/** Strip comments so a key mentioned only in prose does not count as read. */
+/** Strip comments so a leaf named only in prose does not count as read. */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
-/**
- * The control set, taken from the panel's own starting values rather than from
- * a list this test maintains — a list would drift exactly the way the fold
- * drifted. `DEFAULT_SECTIONS` is typed `SectionState`, so TypeScript already
- * forces it to name every control, and every rendered row must bind one of
- * them. "Declared here" and "rendered" are therefore the same set.
- */
-const CONTROL_KEYS = Object.keys(DEFAULT_SECTIONS) as (keyof SectionState)[];
-
-/** All controls on, as the panel's state shape. */
-function allOn(): SectionState {
- return Object.fromEntries(
- CONTROL_KEYS.map((k) => [k, true]),
- ) as unknown as SectionState;
-}
-
-/**
- * The exact path a selection takes in production: panel state → grouped wire
- * shape → the route's Zod parse (which strips anything the schema does not
- * declare) → the flat prefs the aggregator consumes.
- */
-function resolve(state: SectionState): DoctorReportPrefs {
- return toDoctorReportPrefs(
- exportSectionsSchema.parse(buildSelectionSections(state)),
- );
-}
-
-describe("export control gating — structural guard", () => {
- it("reads a plausible control set", () => {
- // Sanity floor: if the control shape silently degraded, every assertion
- // below would pass vacuously.
- expect(CONTROL_KEYS.length).toBeGreaterThanOrEqual(20);
- expect(CONTROL_KEYS).toContain("bp");
- expect(CONTROL_KEYS).toContain("mentalHealthScreeners");
+describe("report scope gating — structural guard", () => {
+ it("reads a plausible catalogue", () => {
+ // Sanity floor: a silently degraded catalogue would pass everything below
+ // vacuously.
+ expect(ALL_LEAF_IDS.length).toBe(90);
+ expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
+ expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(13);
});
- it("renders a row for every control it declares", () => {
- const unrendered = CONTROL_KEYS.filter(
- (key) => !panelSource.includes(`sections.${key}`),
- );
- expect(
- unrendered,
- `These controls are declared in the selection shape but the panel binds ` +
- `no row to them, so the user has no way to reach them.`,
- ).toEqual([]);
- });
-
- it("keeps every rendered control inside the wire schema", () => {
- const wire = buildSelectionSections(allOn());
- // A round trip through the schema must not lose anything the panel sends.
- expect(exportSectionsSchema.parse(wire)).toEqual(wire);
- });
-
- it("makes flipping any single control change the resolved selection", () => {
- const baseline = resolve(allOn());
- const inert: string[] = [];
-
- for (const key of CONTROL_KEYS) {
- const flipped = resolve({ ...allOn(), [key]: false });
- if (JSON.stringify(flipped) === JSON.stringify(baseline)) {
- inert.push(key);
+ it("places every leaf in exactly one group", () => {
+ const seen = new Map();
+ for (const group of REPORT_GROUPS) {
+ for (const leaf of group.leaves) {
+ seen.set(leaf, (seen.get(leaf) ?? 0) + 1);
}
}
+ const duplicated = [...seen.entries()].filter(([, n]) => n > 1);
+ const missing = ALL_LEAF_IDS.filter((leaf) => !seen.has(leaf));
+ expect(duplicated, "a leaf appears in more than one group").toEqual([]);
+ expect(missing, "a leaf is in no group and so is unreachable").toEqual([]);
+ });
+ it("renders every group the catalogue declares", () => {
+ // The picker maps over `REPORT_GROUPS` and splits the fenced tier out by
+ // id, so this is the check that the split cannot silently drop a group.
+ expect(pickerSource).toContain("REPORT_GROUPS.map");
+ expect(pickerSource).toContain("SENSITIVE_GROUP_ID");
+ });
+
+ it("makes flipping any single leaf change the resolved selection", () => {
+ const all = selectionFromLeaves(ALL_LEAF_IDS);
+ const inert: ReportLeafId[] = [];
+ for (const leaf of ALL_LEAF_IDS) {
+ const without = selectionFromLeaves(
+ ALL_LEAF_IDS.filter((l) => l !== leaf),
+ );
+ if (
+ without.has(leaf) ||
+ without.leaves.length !== all.leaves.length - 1
+ ) {
+ inert.push(leaf);
+ }
+ }
expect(
inert,
- `These panel controls are rendered but change nothing in the resolved ` +
- `export selection. Give each one a flag in DoctorReportPrefs and a ` +
- `gating path in doctor-report-data.ts, or remove the control.`,
+ "these leaves cannot be individually withheld, so their control would lie",
).toEqual([]);
});
- it("has something downstream read every flag in the resolved selection", () => {
- // A flag is honoured either by gating measurement types or by name in the
- // aggregator body (the structured sections: labs, allergies, the
- // medication block, the glucose panel).
- const mappedFlags = new Set(
- Object.values(MEASUREMENT_TYPE_SECTION),
+ it("has something downstream read every leaf", () => {
+ // A leaf is honoured either as a measurement type — the gate strips those
+ // by set membership over the whole enum — or by name in the aggregator.
+ const body = stripComments(collectSource);
+ const unread = ALL_LEAF_IDS.filter(
+ (leaf) => isStructuredLeafId(leaf) && !body.includes(`"${leaf}"`),
);
- const body = stripComments(aggregatorSource);
-
- const unread = Object.keys(DEFAULT_DOCTOR_REPORT_PREFS).filter(
- (key) => !mappedFlags.has(key) && !body.includes(`sections.${key}`),
- );
-
expect(
unread,
- `These resolved selection flags are never read downstream, so switching ` +
- `them off would change nothing that leaves the instance.`,
+ "these structured leaves are never read in the aggregator, so switching " +
+ "them off would change nothing that leaves the instance",
).toEqual([]);
});
- it("keeps the sensitive selections off unless asked for by name", () => {
- // Mood, cycle and the screening instruments must never ride an absent key:
- // the mental-health module went default-ON in v1.29.1 and silently made
- // PHQ-9 and GAD-7 totals part of a default export.
- const fromNothing = toDoctorReportPrefs(undefined);
- expect(fromNothing.mood).toBe(false);
- expect(fromNothing.cycle).toBe(false);
- expect(fromNothing.mentalHealthScreeners).toBe(false);
-
- // A partial selection that names other things must not flip them on.
- const partial = toDoctorReportPrefs({ labs: true, vitals: { bp: true } });
- expect(partial.mood).toBe(false);
- expect(partial.cycle).toBe(false);
- expect(partial.mentalHealthScreeners).toBe(false);
-
- // Only an explicit `true` does.
- expect(
- toDoctorReportPrefs({ mentalHealthScreeners: true })
- .mentalHealthScreeners,
- ).toBe(true);
+ it("keeps every sensitive leaf in the fenced tier", () => {
+ for (const leaf of [
+ "MOOD",
+ "CYCLE",
+ "FAMILY_HISTORY",
+ "PHQ9_SCORE",
+ "GAD7_SCORE",
+ "WHO5_SCORE",
+ "SCI_SCORE",
+ ] as ReportLeafId[]) {
+ expect(leafGroup(leaf), `${leaf} must be fenced`).toBe("sensitive");
+ }
});
});
diff --git a/src/__tests__/report-selection-egress-guard.test.ts b/src/__tests__/report-selection-egress-guard.test.ts
new file mode 100644
index 000000000..0601165d1
--- /dev/null
+++ b/src/__tests__/report-selection-egress-guard.test.ts
@@ -0,0 +1,83 @@
+/**
+ * Structural guard: every caller of the doctor-report aggregator is named here
+ * with the source of its selection.
+ *
+ * The aggregator's third parameter is required and brand-typed, so a new
+ * egress path cannot compile without naming a selection source. This is the
+ * second half of that: it cannot be ADDED without a human writing down which
+ * source, in this list, which is the moment a reviewer asks the consent
+ * question. Modelled on `bearer-scope-enforcement-guard.test.ts`.
+ *
+ * Adding a caller fails the build until the list is updated deliberately.
+ *
+ * Mutation check: add a `collectDoctorReportData(` call anywhere under `src/`
+ * that is not in the list → "the set of aggregator callers is frozen" goes red
+ * naming the file.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+const SRC = join(__dirname, "..");
+const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
+
+/**
+ * Every production call site, with the source of its selection. A path here
+ * that no longer calls the aggregator is as much a failure as one that does
+ * and is missing — a stale entry is how a list stops being read.
+ */
+const CALLERS: Record = {
+ "app/api/export/health-record/route.ts":
+ "the request body's `selection` field, minted through selectionFromRequest",
+ "lib/clinician-share/share-view-data.ts":
+ "the share link's frozen sectionsJson, minted through selectionFromStoredBlob",
+ "lib/fhir/rest.ts": "the owner's saved profile (resolveSavedSelection)",
+ "lib/mcp/resources.ts": "the owner's saved profile (resolveSavedSelection)",
+ "lib/mcp/prompts.ts": "the owner's saved profile (resolveSavedSelection)",
+};
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const name of readdirSync(dir)) {
+ if (SKIP_DIRS.has(name)) continue;
+ const p = join(dir, name);
+ if (statSync(p).isDirectory()) walk(p, out);
+ else if (name.endsWith(".ts") || name.endsWith(".tsx")) out.push(p);
+ }
+ return out;
+}
+
+/** Strip comments so a mention in prose is not read as a call. */
+function stripComments(source: string): string {
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
+}
+
+describe("doctor-report aggregator egress", () => {
+ it("keeps the set of aggregator callers frozen", () => {
+ const found: string[] = [];
+ for (const file of walk(SRC)) {
+ const rel = file.slice(SRC.length + 1).replace(/\\/g, "/");
+ // The aggregator's own module and its public surface are not callers.
+ if (rel === "lib/doctor-report/collect.ts") continue;
+ if (rel === "lib/doctor-report-data.ts") continue;
+ const body = stripComments(readFileSync(file, "utf8"));
+ if (body.includes("collectDoctorReportData(")) found.push(rel);
+ }
+
+ const expected = Object.keys(CALLERS).sort();
+ expect(
+ found.sort(),
+ "Every surface that assembles the whole record has to name where its " +
+ "selection came from. Add the file to CALLERS with its source, or " +
+ "remove the call.",
+ ).toEqual(expected);
+ });
+
+ it("names a real selection source for each caller", () => {
+ for (const [path, source] of Object.entries(CALLERS)) {
+ expect(
+ source.length,
+ `${path} has no stated selection source`,
+ ).toBeGreaterThan(10);
+ }
+ });
+});
diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts
index e5815ce7b..f8ab6cc74 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -1,11 +1,16 @@
/**
- * v1.7.0 — POST /api/export/health-record unit coverage.
+ * POST /api/export/health-record unit coverage.
*
* Mock-based (no testcontainers): pins the route contract — strict Zod
- * rejection paths (422 via returnAllZodIssues), rate-limit (429), the
- * three format outputs (PDF magic bytes, application/fhir+json valid
- * Bundle, application/zip), and that the user is narrowed from the
- * session (never the body).
+ * rejection paths (422 via returnAllZodIssues), rate-limit (429), the three
+ * format outputs (PDF magic bytes, application/fhir+json valid Bundle,
+ * application/zip), and that the user is narrowed from the session, never the
+ * body.
+ *
+ * The second half is the artefact-level proof that the selection means the
+ * same thing in every format: each case runs the real route end to end and
+ * reads what comes out, with a positive control beside each exclusion so
+ * nothing can pass on an empty fixture.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -24,6 +29,23 @@ vi.mock("@/lib/db", () => ({
familyHistoryEntry: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
+ toJson: (value: unknown) => value,
+}));
+// The cycle summary has its own aggregation behind it; this suite is about the
+// route, so it is stubbed to a fixed shape. Whether it is CALLED at all is the
+// property that matters here, and the aggregator sweep asserts that directly.
+vi.mock("@/lib/cycle/export-data", () => ({
+ buildCycleExportSummary: vi.fn(async () => ({
+ lastPeriodStart: "2026-02-01",
+ recentCycles: [
+ { startDate: "2026-02-01", lengthDays: 28, periodLengthDays: 5 },
+ ],
+ observedCycleCount: 1,
+ averageCycleLengthDays: 28,
+ cycleLengthVariabilityDays: null,
+ averagePeriodLengthDays: 5,
+ currentPhase: "FOLLICULAR",
+ })),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
@@ -57,6 +79,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
});
import { prisma } from "@/lib/db";
+import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -69,6 +92,16 @@ const SESSION_OK = {
user: { id: "user-1", email: "test@example.com", role: "USER" },
} as const;
+/** A selection body carrying exactly these leaves. */
+function sel(...leaves: string[]) {
+ return { v: 2, leaves };
+}
+
+/** Everything the catalogue knows — the positive-control scope. */
+function allLeaves() {
+ return { v: 2, leaves: [...ALL_LEAF_IDS] };
+}
+
function mkReq(
body: unknown,
headers: Record = {},
@@ -129,19 +162,21 @@ beforeEach(() => {
describe("POST /api/export/health-record — validation", () => {
it("rejects an unknown format with 422", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "xml" }));
+ const res = await POST(mkReq({ format: "xml", selection: sel() }));
expect(res.status).toBe(422);
});
it("rejects a missing format with 422", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({}));
+ const res = await POST(mkReq({ selection: sel() }));
expect(res.status).toBe(422);
});
it("rejects a userId smuggled into the body with 422", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "pdf", userId: "user-2" }));
+ const res = await POST(
+ mkReq({ format: "pdf", selection: sel(), userId: "user-2" }),
+ );
expect(res.status).toBe(422);
});
@@ -155,7 +190,7 @@ describe("POST /api/export/health-record — validation", () => {
}),
} as never);
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "fhir" }));
+ const res = await POST(mkReq({ format: "fhir", selection: sel() }));
expect(res.status).toBe(403);
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "doctorReport");
});
@@ -167,7 +202,7 @@ describe("POST /api/export/health-record — validation", () => {
resetAt: Date.now() + 3_600_000,
} as never);
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "fhir" }));
+ const res = await POST(mkReq({ format: "fhir", selection: sel() }));
expect(res.status).toBe(429);
});
});
@@ -175,7 +210,7 @@ describe("POST /api/export/health-record — validation", () => {
describe("POST /api/export/health-record — outputs", () => {
it("format=fhir returns a valid FHIR document Bundle", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "fhir" }));
+ const res = await POST(mkReq({ format: "fhir", selection: allLeaves() }));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const bundle = await res.json();
@@ -186,7 +221,7 @@ describe("POST /api/export/health-record — outputs", () => {
it("format=pdf returns a PDF with the %PDF- magic bytes", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "pdf" }));
+ const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
const buf = Buffer.from(await res.arrayBuffer());
@@ -195,7 +230,9 @@ describe("POST /api/export/health-record — outputs", () => {
it("format=package returns a zip", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "package" }));
+ const res = await POST(
+ mkReq({ format: "package", selection: allLeaves() }),
+ );
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/zip");
const buf = Buffer.from(await res.arrayBuffer());
@@ -206,7 +243,12 @@ describe("POST /api/export/health-record — outputs", () => {
it("renders the PDF in the explicit selection locale", async () => {
const { POST } = await import("../route");
const res = await POST(
- mkReq({ format: "pdf", locale: "de", practiceName: "Sample Practice" }),
+ mkReq({
+ format: "pdf",
+ locale: "de",
+ selection: allLeaves(),
+ practiceName: "Sample Practice",
+ }),
);
const text = await pdfText(res);
// German cover label.
@@ -217,7 +259,11 @@ describe("POST /api/export/health-record — outputs", () => {
const { POST } = await import("../route");
const res = await POST(
mkReq(
- { format: "pdf", practiceName: "Sample Practice" },
+ {
+ format: "pdf",
+ selection: allLeaves(),
+ practiceName: "Sample Practice",
+ },
{
// Browser default English, but the in-app cookie says German — the
// cookie must win over Accept-Language.
@@ -235,7 +281,11 @@ describe("POST /api/export/health-record — outputs", () => {
const { POST } = await import("../route");
const res = await POST(
mkReq(
- { format: "pdf", practiceName: "Sample Practice" },
+ {
+ format: "pdf",
+ selection: allLeaves(),
+ practiceName: "Sample Practice",
+ },
{ "accept-language": "en-US,en;q=0.9" },
),
);
@@ -245,10 +295,12 @@ describe("POST /api/export/health-record — outputs", () => {
it("scopes the aggregator measurement read to the session user", async () => {
const { POST } = await import("../route");
- await POST(mkReq({ format: "fhir", userId: "user-2" } as never));
+ await POST(
+ mkReq({ format: "fhir", selection: sel(), userId: "user-2" } as never),
+ );
// userId smuggle is rejected above; here we confirm the read uses the
// session user, not anything from the body, for a clean payload.
- await POST(mkReq({ format: "fhir" }));
+ await POST(mkReq({ format: "fhir", selection: allLeaves() }));
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user-1", deletedAt: null }),
@@ -335,11 +387,12 @@ describe("POST /api/export/health-record — the selection reaches every format"
}
/** Resource types present in the bundle a given selection produces. */
- async function bundleResourceTypes(
- sections: Record,
- ): Promise {
+ async function bundleResourceTypes(selection: {
+ v: number;
+ leaves: string[];
+ }): Promise {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "fhir", sections }));
+ const res = await POST(mkReq({ format: "fhir", selection }));
expect(res.status).toBe(200);
const bundle = (await res.json()) as {
entry: { resource: { resourceType: string; code?: unknown } }[];
@@ -348,11 +401,12 @@ describe("POST /api/export/health-record — the selection reaches every format"
}
/** The LOINC/HealthKit codes of every Observation a selection produces. */
- async function observationCodes(
- sections: Record,
- ): Promise {
+ async function observationCodes(selection: {
+ v: number;
+ leaves: string[];
+ }): Promise {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "fhir", sections }));
+ const res = await POST(mkReq({ format: "fhir", selection }));
expect(res.status).toBe(200);
const bundle = (await res.json()) as {
entry: {
@@ -382,36 +436,35 @@ describe("POST /api/export/health-record — the selection reaches every format"
measurement("WALKING_RUNNING_DISTANCE", 6200),
] as never);
- // Positive control: with every switch on, all eight are emitted. Without
- // this the exclusion assertion below could pass on an empty bundle.
- const on = await observationCodes({
- vitals: { oxygenSaturation: true, bodyFat: true, bodyComposition: true },
- cardioFitness: { restingHeartRate: true, hrv: true, vo2max: true },
- activity: { steps: true, distance: true },
- });
+ // Positive control: with all eight leaves chosen, all eight are emitted.
+ // Without this the exclusion assertion below could pass on an empty bundle.
+ const on = await observationCodes(
+ sel(
+ "BODY_FAT",
+ "OXYGEN_SATURATION",
+ "BONE_MASS",
+ "RESTING_HEART_RATE",
+ "HEART_RATE_VARIABILITY",
+ "VO2_MAX",
+ "ACTIVITY_STEPS",
+ "WALKING_RUNNING_DISTANCE",
+ ),
+ );
expect(on.length).toBeGreaterThanOrEqual(8);
- const off = await observationCodes({
- vitals: {
- oxygenSaturation: false,
- bodyFat: false,
- bodyComposition: false,
- },
- cardioFitness: { restingHeartRate: false, hrv: false, vo2max: false },
- activity: { steps: false, distance: false },
- });
+ // A selection naming something else entirely carries none of them —
+ // absence of a leaf is exclusion, not a default.
+ const off = await observationCodes(sel("PULSE"));
expect(off).toEqual([]);
});
it("keeps the deselected measurement rows out of the query in the first place", async () => {
- await bundleResourceTypes({
- vitals: { bodyFat: false },
- cardioFitness: { vo2max: false },
- });
+ await bundleResourceTypes(sel("PULSE"));
const lastCall = vi.mocked(prisma.measurement.findMany).mock.calls.at(-1)!;
const where = lastCall[0]!.where as { type?: { notIn?: string[] } };
expect(where.type?.notIn).toContain("BODY_FAT");
expect(where.type?.notIn).toContain("VO2_MAX");
+ expect(where.type?.notIn).not.toContain("PULSE");
});
it("omits the deselected vitals from the PDF table", async () => {
@@ -426,22 +479,22 @@ describe("POST /api/export/health-record — the selection reaches every format"
mkReq({
format: "pdf",
locale: "en",
- sections: { vitals: { bodyFat: true, oxygenSaturation: true } },
+ selection: sel("BODY_FAT", "OXYGEN_SATURATION"),
}),
),
);
- expect(withBoth).toContain("Body fat");
+ expect(withBoth).toContain("Body Fat");
const without = await pdfText(
await POST(
mkReq({
format: "pdf",
locale: "en",
- sections: { vitals: { bodyFat: false, oxygenSaturation: false } },
+ selection: sel("PULSE"),
}),
),
);
- expect(without).not.toContain("Body fat");
+ expect(without).not.toContain("Body Fat");
});
it("holds the mental-health screening totals back unless they are asked for by name", async () => {
@@ -450,12 +503,13 @@ describe("POST /api/export/health-record — the selection reaches every format"
measurement("GAD7_SCORE", 8),
] as never);
- // The module is on (the default since v1.29.1) and the caller sends no
- // screening key: the totals must stay in the database.
- expect(await observationCodes({ labs: true })).toEqual([]);
- // And an omitted `sections` object entirely — the MCP / share-link case.
+ // The module is on (the default since v1.29.1) and the caller names other
+ // leaves: the totals must stay in the database.
+ expect(await observationCodes(sel("LAB_RESULTS"))).toEqual([]);
+ // And an empty selection — the "I said nothing" case, which used to mean
+ // "give me everything".
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "fhir" }));
+ const res = await POST(mkReq({ format: "fhir", selection: sel() }));
const bundle = (await res.json()) as {
entry: { resource: { resourceType: string } }[];
};
@@ -463,8 +517,8 @@ describe("POST /api/export/health-record — the selection reaches every format"
bundle.entry.filter((e) => e.resource.resourceType === "Observation"),
).toEqual([]);
- // Asked for explicitly, they are emitted — the data is withheld, not lost.
- const asked = await observationCodes({ mentalHealthScreeners: true });
+ // Asked for by name, they are emitted — the data is withheld, not lost.
+ const asked = await observationCodes(sel("PHQ9_SCORE", "GAD7_SCORE"));
expect(asked).toContain("44261-6");
expect(asked).toContain("70274-6");
});
@@ -474,11 +528,11 @@ describe("POST /api/export/health-record — the selection reaches every format"
medicationRow(),
] as never);
+ expect(await bundleResourceTypes(sel("MEDICATION_LIST"))).toContain(
+ "MedicationStatement",
+ );
expect(
- await bundleResourceTypes({ medications: { list: true } }),
- ).toContain("MedicationStatement");
- expect(
- await bundleResourceTypes({ medications: { list: false } }),
+ await bundleResourceTypes(sel("MEDICATION_COMPLIANCE")),
).not.toContain("MedicationStatement");
});
@@ -490,23 +544,17 @@ describe("POST /api/export/health-record — the selection reaches every format"
familyHistoryRow(),
] as never);
- const on = await bundleResourceTypes({
- allergies: true,
- familyHistory: true,
- });
+ const on = await bundleResourceTypes(sel("ALLERGIES", "FAMILY_HISTORY"));
expect(on).toContain("AllergyIntolerance");
expect(on).toContain("FamilyMemberHistory");
- const off = await bundleResourceTypes({
- allergies: false,
- familyHistory: false,
- });
+ const off = await bundleResourceTypes(sel("PULSE"));
expect(off).not.toContain("AllergyIntolerance");
expect(off).not.toContain("FamilyMemberHistory");
});
it("never reads the deselected records from the database", async () => {
- await bundleResourceTypes({ allergies: false, familyHistory: false });
+ await bundleResourceTypes(sel("PULSE"));
expect(prisma.allergy.findMany).not.toHaveBeenCalled();
expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled();
});
@@ -521,10 +569,7 @@ describe("POST /api/export/health-record — the selection reaches every format"
const { POST } = await import("../route");
const res = await POST(
- mkReq({
- format: "package",
- sections: { allergies: false, familyHistory: false },
- }),
+ mkReq({ format: "package", selection: sel("PULSE") }),
);
expect(res.status).toBe(200);
const files = unzipSync(new Uint8Array(await res.arrayBuffer()));
@@ -539,53 +584,111 @@ describe("POST /api/export/health-record — the selection reaches every format"
});
/**
- * The practice name is a remembered preference (`User.lastReportPracticeName`),
- * so a monthly report does not ask for the clinic name again every time. The
- * write happens only once the artefact exists — a generation that throws must
- * not overwrite the remembered value with a half-typed one.
+ * What the route remembers: the practice name and the scope the owner chose,
+ * so the panel opens carrying both next time and the surfaces that cannot ask
+ * a human have a real act to replay.
*
- * Mutation check: drop the `rememberPracticeName` call in the route and the
- * first two cases go red; move it above the generation and the
- * failed-generation case goes red.
+ * The write happens only once the artefact exists — a generation that throws
+ * must not overwrite the remembered values with a half-typed name or a scope
+ * that produced nothing.
+ *
+ * Mutation checks: drop the `rememberChoices` call and the first cases go red;
+ * move it above the generation and the failed-generation case goes red; drop
+ * `reportSelectionJson` from the update and "remembers the chosen scope" goes
+ * red.
*/
-describe("POST /api/export/health-record — remembered practice name", () => {
+describe("POST /api/export/health-record — what the route remembers", () => {
it("persists the sanitised practice name after a PDF export", async () => {
const { POST } = await import("../route");
const res = await POST(
- mkReq({ format: "pdf", practiceName: " Sample Practice " }),
+ mkReq({
+ format: "pdf",
+ selection: sel("WEIGHT"),
+ practiceName: " Sample Practice ",
+ }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
- data: { lastReportPracticeName: "Sample Practice" },
+ data: expect.objectContaining({
+ lastReportPracticeName: "Sample Practice",
+ }),
});
});
+ it("remembers the chosen scope, in catalogue order", async () => {
+ const { POST } = await import("../route");
+ const res = await POST(
+ mkReq({
+ format: "fhir",
+ selection: sel("LAB_RESULTS", "WEIGHT"),
+ range: { days: 180 },
+ includeCharts: false,
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(prisma.user.update).toHaveBeenCalledWith({
+ where: { id: "user-1" },
+ data: expect.objectContaining({
+ reportSelectionJson: {
+ v: 2,
+ leaves: ["WEIGHT", "LAB_RESULTS"],
+ format: "fhir",
+ rangeDays: 180,
+ includeCharts: false,
+ },
+ }),
+ });
+ });
+
+ it("refuses a leaf id this build does not know, and remembers nothing", async () => {
+ const { POST } = await import("../route");
+ const res = await POST(
+ mkReq({ format: "pdf", selection: sel("SOMETHING_NEW") }),
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("SOMETHING_NEW");
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
+
it("persists the practice name after a FHIR export", async () => {
const { POST } = await import("../route");
const res = await POST(
- mkReq({ format: "fhir", practiceName: "Sample Practice" }),
+ mkReq({
+ format: "fhir",
+ selection: sel("WEIGHT"),
+ practiceName: "Sample Practice",
+ }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
- data: { lastReportPracticeName: "Sample Practice" },
+ data: expect.objectContaining({
+ lastReportPracticeName: "Sample Practice",
+ }),
});
});
it("persists null when the user cleared the practice name", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "pdf" }));
+ const res = await POST(mkReq({ format: "pdf", selection: sel("WEIGHT") }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
- data: { lastReportPracticeName: null },
+ data: expect.objectContaining({ lastReportPracticeName: null }),
});
});
it("writes nothing when the selection is rejected", async () => {
const { POST } = await import("../route");
- const res = await POST(mkReq({ format: "xml", practiceName: "Sample" }));
+ const res = await POST(
+ mkReq({
+ format: "xml",
+ selection: sel("WEIGHT"),
+ practiceName: "Sample",
+ }),
+ );
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
@@ -596,7 +699,11 @@ describe("POST /api/export/health-record — remembered practice name", () => {
);
const { POST } = await import("../route");
const res = await POST(
- mkReq({ format: "pdf", practiceName: "Sample Practice" }),
+ mkReq({
+ format: "pdf",
+ selection: sel("WEIGHT"),
+ practiceName: "Sample Practice",
+ }),
);
expect(res.status).toBe(500);
expect(prisma.user.update).not.toHaveBeenCalled();
@@ -604,7 +711,13 @@ describe("POST /api/export/health-record — remembered practice name", () => {
it("narrows the update to the session user, never the body", async () => {
const { POST } = await import("../route");
- await POST(mkReq({ format: "fhir", practiceName: "Sample Practice" }));
+ await POST(
+ mkReq({
+ format: "fhir",
+ selection: sel("WEIGHT"),
+ practiceName: "Sample Practice",
+ }),
+ );
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "user-1" } }),
);
diff --git a/src/app/api/fhir/Coverage/[id]/route.ts b/src/app/api/fhir/Coverage/[id]/route.ts
new file mode 100644
index 000000000..e721c709c
--- /dev/null
+++ b/src/app/api/fhir/Coverage/[id]/route.ts
@@ -0,0 +1,50 @@
+/**
+ * GET /api/fhir/Coverage/{id} — FHIR R4 `read` of one Coverage from the
+ * caller's own record.
+ *
+ * The id space is the export's own (`coverage-1`), stable for a given record and
+ * reporting window. A `read` is a filter over the same emitter the search
+ * route pages, so a resource can never differ between the two faces; an id the
+ * caller's record does not hold is a `not-found` OperationOutcome.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { coverageResource } from "@/lib/fhir/resources";
+import {
+ FHIR_READ_SCOPE,
+ loadFhirContext,
+ operationOutcome,
+ readResponse,
+} from "@/lib/fhir/rest";
+
+export const GET = apiHandler(
+ async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
+ const { user } = await requireAuth(FHIR_READ_SCOPE);
+ annotate({ action: { name: "fhir.coverage.read" } });
+
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
+ const gate = await requireModuleEnabled(user.id, "doctorReport");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return operationOutcome(429, "throttled", "Rate limit exceeded");
+ }
+ const { id } = await ctx.params;
+ const context = await loadFhirContext(user.id);
+ annotate({ meta: { resourceId: id } });
+ return readResponse([coverageResource(context.data, context.identity)].filter(
+ (resource) => resource !== null,
+ ), id);
+ },
+);
diff --git a/src/app/api/fhir/Coverage/route.ts b/src/app/api/fhir/Coverage/route.ts
new file mode 100644
index 000000000..ce45cbc0f
--- /dev/null
+++ b/src/app/api/fhir/Coverage/route.ts
@@ -0,0 +1,55 @@
+/**
+ * GET /api/fhir/Coverage — FHIR R4 `searchset` of the caller's own insurance Coverage.
+ *
+ * Present only when an insurer is known — R4 requires a payor, and a record
+ * that carries only a member id has none, so the search is empty rather than
+ * carrying a Coverage that names nobody.
+ *
+ * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
+ * `@/lib/fhir/resources` are the single source of the coding, so this face and
+ * the document export can never describe the same record differently. Offset
+ * paging via `_count` (clamped ≤200) / `_offset`.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { coverageResource } from "@/lib/fhir/resources";
+import {
+ FHIR_READ_SCOPE,
+ loadFhirContext,
+ operationOutcome,
+ parsePaging,
+ searchsetResponse,
+} from "@/lib/fhir/rest";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth(FHIR_READ_SCOPE);
+ annotate({ action: { name: "fhir.coverage.search" } });
+
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
+ const gate = await requireModuleEnabled(user.id, "doctorReport");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return operationOutcome(429, "throttled", "Rate limit exceeded");
+ }
+ const { count, offset } = parsePaging(request.nextUrl.searchParams);
+ const context = await loadFhirContext(user.id);
+
+ const all = [coverageResource(context.data, context.identity)].filter(
+ (resource) => resource !== null,
+ );
+ const page = all.slice(offset, offset + count);
+ annotate({ meta: { total: all.length, count, offset } });
+ return searchsetResponse(request.nextUrl, page, all.length, count, offset);
+});
diff --git a/src/app/api/fhir/MedicationAdministration/[id]/route.ts b/src/app/api/fhir/MedicationAdministration/[id]/route.ts
new file mode 100644
index 000000000..5725ef1dc
--- /dev/null
+++ b/src/app/api/fhir/MedicationAdministration/[id]/route.ts
@@ -0,0 +1,53 @@
+/**
+ * GET /api/fhir/MedicationAdministration/{id} — FHIR R4 `read` of one MedicationAdministration from the
+ * caller's own record.
+ *
+ * The id space is the export's own (`medadmin-7`), stable for a given record and
+ * reporting window. A `read` is a filter over the same emitter the search
+ * route pages, so a resource can never differ between the two faces; an id the
+ * caller's record does not hold is a `not-found` OperationOutcome.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources";
+import {
+ FHIR_READ_SCOPE,
+ loadFhirContext,
+ operationOutcome,
+ readResponse,
+} from "@/lib/fhir/rest";
+
+export const GET = apiHandler(
+ async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
+ const { user } = await requireAuth(FHIR_READ_SCOPE);
+ annotate({ action: { name: "fhir.medicationadministration.read" } });
+
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
+ const gate = await requireModuleEnabled(user.id, "doctorReport");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return operationOutcome(429, "throttled", "Rate limit exceeded");
+ }
+ const { id } = await ctx.params;
+ const context = await loadFhirContext(user.id);
+ annotate({ meta: { resourceId: id } });
+ return readResponse(
+ medicationAdministrationsFromReportData(context.data, {
+ germanAtc: context.germanAtc,
+ }),
+ id,
+ );
+ },
+);
diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts
index 747b8f71b..361d1eed6 100644
--- a/src/app/api/fhir/MedicationAdministration/route.ts
+++ b/src/app/api/fhir/MedicationAdministration/route.ts
@@ -1,10 +1,12 @@
/**
- * GET /api/fhir/MedicationAdministration — FHIR R4 `searchset` of the caller's
- * own acted intakes (`completed` taken / `not-done` skip), with the same
- * ATC/RxNorm + dose/route/site coding the document export carries.
+ * GET /api/fhir/MedicationAdministration — FHIR R4 `searchset` of the caller's own acted
+ * intakes (`completed` taken / `not-done` skip), with the same ATC/RxNorm +
+ * dose/route/site coding the document export carries.
*
- * Read-only. `userId` narrowed from `requireAuth`; shared emitter is the
- * single source of the coding. Offset paging via `_count` (≤200) / `_offset`.
+ * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
+ * `@/lib/fhir/resources` are the single source of the coding, so this face and
+ * the document export can never describe the same record differently. Offset
+ * paging via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
@@ -25,18 +27,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationadministration.search" } });
- // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
- // `/api/export/health-record` (the doctor-report builder), right down to
- // the decrypted insurance number on the Patient resource. That export
- // gates on the `doctorReport` module; this surface did not, so the module
- // could be off and `/api/fhir/*` still handed out the full record.
- //
- // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
- // a whole-record export, not an incremental feed: there is no partial
- // answer that is still a truthful FHIR Bundle, and no background client
- // depends on it draining to stay consistent. Mirroring the sibling export's
- // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
- // the errorCode the clients already branch on for a disabled module.
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
@@ -44,11 +41,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
-
const { count, offset } = parsePaging(request.nextUrl.searchParams);
- const { data, germanAtc } = await loadFhirContext(user.id);
+ const context = await loadFhirContext(user.id);
- const all = medicationAdministrationsFromReportData(data, { germanAtc });
+ const all = medicationAdministrationsFromReportData(context.data, {
+ germanAtc: context.germanAtc,
+ });
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
diff --git a/src/app/api/fhir/MedicationStatement/[id]/route.ts b/src/app/api/fhir/MedicationStatement/[id]/route.ts
new file mode 100644
index 000000000..d3366f236
--- /dev/null
+++ b/src/app/api/fhir/MedicationStatement/[id]/route.ts
@@ -0,0 +1,53 @@
+/**
+ * GET /api/fhir/MedicationStatement/{id} — FHIR R4 `read` of one MedicationStatement from the
+ * caller's own record.
+ *
+ * The id space is the export's own (`med-2`), stable for a given record and
+ * reporting window. A `read` is a filter over the same emitter the search
+ * route pages, so a resource can never differ between the two faces; an id the
+ * caller's record does not hold is a `not-found` OperationOutcome.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { medicationStatementsFromReportData } from "@/lib/fhir/resources";
+import {
+ FHIR_READ_SCOPE,
+ loadFhirContext,
+ operationOutcome,
+ readResponse,
+} from "@/lib/fhir/rest";
+
+export const GET = apiHandler(
+ async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
+ const { user } = await requireAuth(FHIR_READ_SCOPE);
+ annotate({ action: { name: "fhir.medicationstatement.read" } });
+
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
+ const gate = await requireModuleEnabled(user.id, "doctorReport");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return operationOutcome(429, "throttled", "Rate limit exceeded");
+ }
+ const { id } = await ctx.params;
+ const context = await loadFhirContext(user.id);
+ annotate({ meta: { resourceId: id } });
+ return readResponse(
+ medicationStatementsFromReportData(context.data, {
+ germanAtc: context.germanAtc,
+ }),
+ id,
+ );
+ },
+);
diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts
index b7565735d..bd205854c 100644
--- a/src/app/api/fhir/MedicationStatement/route.ts
+++ b/src/app/api/fhir/MedicationStatement/route.ts
@@ -3,9 +3,10 @@
* active-medication statements (one per active medication, with additive
* ATC/RxNorm codings).
*
- * Read-only. `userId` narrowed from `requireAuth`; shared emitter is the
- * single source of the drug coding. Offset paging via `_count` (≤200) /
- * `_offset`.
+ * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
+ * `@/lib/fhir/resources` are the single source of the coding, so this face and
+ * the document export can never describe the same record differently. Offset
+ * paging via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
@@ -26,18 +27,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationstatement.search" } });
- // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
- // `/api/export/health-record` (the doctor-report builder), right down to
- // the decrypted insurance number on the Patient resource. That export
- // gates on the `doctorReport` module; this surface did not, so the module
- // could be off and `/api/fhir/*` still handed out the full record.
- //
- // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
- // a whole-record export, not an incremental feed: there is no partial
- // answer that is still a truthful FHIR Bundle, and no background client
- // depends on it draining to stay consistent. Mirroring the sibling export's
- // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
- // the errorCode the clients already branch on for a disabled module.
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
@@ -45,11 +41,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
-
const { count, offset } = parsePaging(request.nextUrl.searchParams);
- const { data, germanAtc } = await loadFhirContext(user.id);
+ const context = await loadFhirContext(user.id);
- const all = medicationStatementsFromReportData(data, { germanAtc });
+ const all = medicationStatementsFromReportData(context.data, {
+ germanAtc: context.germanAtc,
+ });
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
diff --git a/src/app/api/fhir/Observation/[id]/route.ts b/src/app/api/fhir/Observation/[id]/route.ts
new file mode 100644
index 000000000..45de4c5e6
--- /dev/null
+++ b/src/app/api/fhir/Observation/[id]/route.ts
@@ -0,0 +1,48 @@
+/**
+ * GET /api/fhir/Observation/{id} — FHIR R4 `read` of one Observation from the
+ * caller's own record.
+ *
+ * The id space is the export's own (`obs-3`), stable for a given record and
+ * reporting window. A `read` is a filter over the same emitter the search
+ * route pages, so a resource can never differ between the two faces; an id the
+ * caller's record does not hold is a `not-found` OperationOutcome.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { observationsFromReportData } from "@/lib/fhir/resources";
+import {
+ FHIR_READ_SCOPE,
+ loadFhirContext,
+ operationOutcome,
+ readResponse,
+} from "@/lib/fhir/rest";
+
+export const GET = apiHandler(
+ async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
+ const { user } = await requireAuth(FHIR_READ_SCOPE);
+ annotate({ action: { name: "fhir.observation.read" } });
+
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
+ const gate = await requireModuleEnabled(user.id, "doctorReport");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return operationOutcome(429, "throttled", "Rate limit exceeded");
+ }
+ const { id } = await ctx.params;
+ const context = await loadFhirContext(user.id);
+ annotate({ meta: { resourceId: id } });
+ return readResponse(observationsFromReportData(context.data), id);
+ },
+);
diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts
index 22760e71b..ab52fcc54 100644
--- a/src/app/api/fhir/Observation/route.ts
+++ b/src/app/api/fhir/Observation/route.ts
@@ -1,11 +1,12 @@
/**
- * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own
- * Observations (vitals / activity / lab / survey), one latest reading per
- * type plus the BP panel, BMI, glucose, adherence, mood and wellness scores.
+ * GET /api/fhir/Observation — FHIR R4 `searchset` of the caller's own Observations
+ * (vitals / activity / lab / survey): one latest reading per type plus the BP
+ * panel, BMI, glucose, adherence, mood and the wellness composites.
*
- * Read-only. `userId` is narrowed from `requireAuth`; the shared emitter is
- * the single source of the LOINC/UCUM coding. Offset paging via `_count`
- * (clamped ≤200) / `_offset`.
+ * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
+ * `@/lib/fhir/resources` are the single source of the coding, so this face and
+ * the document export can never describe the same record differently. Offset
+ * paging via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
@@ -26,18 +27,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.observation.search" } });
- // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
- // `/api/export/health-record` (the doctor-report builder), right down to
- // the decrypted insurance number on the Patient resource. That export
- // gates on the `doctorReport` module; this surface did not, so the module
- // could be off and `/api/fhir/*` still handed out the full record.
- //
- // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
- // a whole-record export, not an incremental feed: there is no partial
- // answer that is still a truthful FHIR Bundle, and no background client
- // depends on it draining to stay consistent. Mirroring the sibling export's
- // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
- // the errorCode the clients already branch on for a disabled module.
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
@@ -45,11 +41,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
-
const { count, offset } = parsePaging(request.nextUrl.searchParams);
- const { data, identity, germanAtc } = await loadFhirContext(user.id);
+ const context = await loadFhirContext(user.id);
- const all = observationsFromReportData(data, identity, { germanAtc });
+ const all = observationsFromReportData(context.data);
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
diff --git a/src/app/api/fhir/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts
similarity index 58%
rename from src/app/api/fhir/$everything/route.ts
rename to src/app/api/fhir/Patient/$everything/route.ts
index e42a49b80..df9318b9f 100644
--- a/src/app/api/fhir/$everything/route.ts
+++ b/src/app/api/fhir/Patient/$everything/route.ts
@@ -1,11 +1,19 @@
/**
- * GET /api/fhir/$everything — FHIR R4 `$everything` operation.
+ * GET /api/fhir/Patient/$everything — FHIR R4 `Patient-everything`.
*
- * Returns every resource in the caller's own record — Patient, Coverage,
- * Observations, MedicationStatements, MedicationAdministrations — in one
- * `searchset` Bundle, in the canonical document order. Read-only; `userId`
- * narrowed from `requireAuth`. Offset paging applies across the flattened
- * resource list via `_count` (clamped ≤200) / `_offset`.
+ * Returns every resource in the caller's own record in one `searchset` Bundle:
+ * the Composition, the authoring Device, Patient, Coverage, every Observation
+ * (vitals / glucose / labs / adherence / mood / wellness AND the cycle set),
+ * MedicationStatements, MedicationAdministrations, Conditions, Encounters,
+ * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport.
+ *
+ * The set is the document export's, flattened — same builder, same order, same
+ * `urn:uuid` entry identities, so a reference between two resources still
+ * resolves inside the response and the operation can never quietly carry less
+ * than the document does.
+ *
+ * Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
+ * the flattened resource list via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
@@ -13,20 +21,13 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
-import {
- coverageResource,
- medicationAdministrationsFromReportData,
- medicationStatementsFromReportData,
- observationsFromReportData,
- patientResource,
-} from "@/lib/fhir/resources";
-import type { FhirResource } from "@/lib/fhir/types";
+import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
FHIR_READ_SCOPE,
loadFhirContext,
operationOutcome,
parsePaging,
- searchsetResponse,
+ searchsetResponseFromEntries,
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -54,16 +55,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { count, offset } = parsePaging(request.nextUrl.searchParams);
- const { data, identity, germanAtc } = await loadFhirContext(user.id);
-
- const all: FhirResource[] = [patientResource(data, identity)];
- const coverage = coverageResource(data, identity);
- if (coverage) all.push(coverage);
- all.push(...observationsFromReportData(data, identity, { germanAtc }));
- all.push(...medicationStatementsFromReportData(data, { germanAtc }));
- all.push(...medicationAdministrationsFromReportData(data, { germanAtc }));
+ const { data, identity, germanAtc, records } = await loadFhirContext(user.id);
+ const document = buildFhirDocumentBundle(
+ data,
+ identity,
+ undefined,
+ { germanAtc },
+ records,
+ );
+ const all = document.entry;
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
- return searchsetResponse(request.nextUrl, page, all.length, count, offset);
+ return searchsetResponseFromEntries(
+ request.nextUrl,
+ page,
+ all.length,
+ count,
+ offset,
+ );
});
diff --git a/src/app/api/fhir/Patient/[id]/route.ts b/src/app/api/fhir/Patient/[id]/route.ts
new file mode 100644
index 000000000..6e5ef3a3c
--- /dev/null
+++ b/src/app/api/fhir/Patient/[id]/route.ts
@@ -0,0 +1,48 @@
+/**
+ * GET /api/fhir/Patient/{id} — FHIR R4 `read` of one Patient from the
+ * caller's own record.
+ *
+ * The id space is the export's own (`patient-1`), stable for a given record and
+ * reporting window. A `read` is a filter over the same emitter the search
+ * route pages, so a resource can never differ between the two faces; an id the
+ * caller's record does not hold is a `not-found` OperationOutcome.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { patientResource } from "@/lib/fhir/resources";
+import {
+ FHIR_READ_SCOPE,
+ loadFhirContext,
+ operationOutcome,
+ readResponse,
+} from "@/lib/fhir/rest";
+
+export const GET = apiHandler(
+ async (_request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
+ const { user } = await requireAuth(FHIR_READ_SCOPE);
+ annotate({ action: { name: "fhir.patient.read" } });
+
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
+ const gate = await requireModuleEnabled(user.id, "doctorReport");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
+ if (!rl.allowed) {
+ return operationOutcome(429, "throttled", "Rate limit exceeded");
+ }
+ const { id } = await ctx.params;
+ const context = await loadFhirContext(user.id);
+ annotate({ meta: { resourceId: id } });
+ return readResponse([patientResource(context.data, context.identity)], id);
+ },
+);
diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts
index c6e2f3b74..dcf03b802 100644
--- a/src/app/api/fhir/Patient/route.ts
+++ b/src/app/api/fhir/Patient/route.ts
@@ -1,10 +1,14 @@
/**
- * GET /api/fhir/Patient — FHIR R4 `searchset` of the caller's own Patient.
+ * GET /api/fhir/Patient — FHIR R4 `searchset` of the caller's own Patient resource.
*
- * Read-only. A FHIR `Patient` search always yields exactly the authenticated
- * user's single Patient resource (the `userId` is narrowed from `requireAuth`;
- * there is no cross-user search). Returned as a one-entry `searchset` Bundle
- * so a generic FHIR client can page it like any other resource type.
+ * A Patient search always yields exactly the authenticated user's single
+ * Patient (there is no cross-user search); it is returned as a one-entry
+ * Bundle so a generic FHIR client can page it like any other resource type.
+ *
+ * Read-only. `userId` is narrowed from `requireAuth`; the shared emitters in
+ * `@/lib/fhir/resources` are the single source of the coding, so this face and
+ * the document export can never describe the same record differently. Offset
+ * paging via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
@@ -25,18 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.patient.search" } });
- // v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
- // `/api/export/health-record` (the doctor-report builder), right down to
- // the decrypted insurance number on the Patient resource. That export
- // gates on the `doctorReport` module; this surface did not, so the module
- // could be off and `/api/fhir/*` still handed out the full record.
- //
- // REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
- // a whole-record export, not an incremental feed: there is no partial
- // answer that is still a truthful FHIR Bundle, and no background client
- // depends on it draining to stay consistent. Mirroring the sibling export's
- // 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
- // the errorCode the clients already branch on for a disabled module.
+ // REFUSE, NOT OMIT. The FHIR face serves the SAME whole-record aggregate as
+ // `/api/export/health-record`, right down to the decrypted insurance number
+ // on the Patient resource, so it gates on the same `doctorReport` module.
+ // There is no partial answer that is still a truthful FHIR Bundle, and no
+ // background client depends on this draining to stay consistent. The 403
+ // `module.disabled` envelope (rather than an OperationOutcome) keeps the
+ // errorCode clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
@@ -44,11 +43,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
}
-
const { count, offset } = parsePaging(request.nextUrl.searchParams);
- const { data, identity } = await loadFhirContext(user.id);
+ const context = await loadFhirContext(user.id);
- const all = [patientResource(data, identity)];
+ const all = [patientResource(context.data, context.identity)];
const page = all.slice(offset, offset + count);
annotate({ meta: { total: all.length, count, offset } });
return searchsetResponse(request.nextUrl, page, all.length, count, offset);
diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts
new file mode 100644
index 000000000..22f1f077f
--- /dev/null
+++ b/src/app/api/fhir/__tests__/capability-and-everything.test.ts
@@ -0,0 +1,360 @@
+/**
+ * The CapabilityStatement must describe a face that exists, and
+ * `Patient/$everything` must return the whole record.
+ *
+ * A statement is a promise a generic FHIR client acts on: it will issue a
+ * `read` because the statement said `read` is there. So the advertised set is
+ * checked against the route tree on disk, not against a second list. And the
+ * whole-record operation is checked against the document export it mirrors —
+ * "everything" that quietly omits a resource family is the kind of gap only a
+ * clinician notices, when the family is the one they needed.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+import { existsSync } from "node:fs";
+import path from "node:path";
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/modules/gate", () => ({
+ requireModuleEnabled: vi.fn(),
+ isModuleEnabled: vi.fn(),
+}));
+vi.mock("@/lib/fhir/rest", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, loadFhirContext: vi.fn() };
+});
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET as metadataGet } from "../metadata/route";
+import { GET as everythingGet } from "../Patient/$everything/route";
+import { GET as observationRead } from "../Observation/[id]/route";
+import { GET as patientRead } from "../Patient/[id]/route";
+import { GET as coverageRead } from "../Coverage/[id]/route";
+import { getSession } from "@/lib/auth/session";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { loadFhirContext, FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest";
+import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
+import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
+import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "tester", role: "USER" as const },
+};
+
+const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
+
+const DATA = {
+ period: {
+ days: 90,
+ since: "2026-02-02T00:00:00.000Z",
+ start: "2026-02-02T00:00:00.000Z",
+ end: "2026-05-03T12:00:00.000Z",
+ },
+ patient: {
+ username: "sample-user",
+ dateOfBirth: "1985-06-15T00:00:00.000Z",
+ gender: "MALE",
+ heightCm: 182,
+ fullName: "Sample Patient",
+ insurerName: "Example Insurer",
+ insurerIkNumber: "101234567",
+ },
+ practiceName: null,
+ measurements: {
+ WEIGHT: [{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }],
+ },
+ stats: {},
+ glucoseStats: {},
+ glucoseRanges: {},
+ glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
+ glucoseUnit: "mg/dL",
+ bmi: 24.1,
+ compliance: {},
+ medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }],
+ medicationAdministrations: [
+ {
+ medicationName: "Example Drug",
+ effectiveAt: "2026-04-30T08:00:00.000Z",
+ status: "completed",
+ doseText: "5mg",
+ dose: { value: 5, unit: "mg" },
+ injectionSite: null,
+ atcCode: null,
+ rxNormCode: null,
+ deliveryForm: "ORAL",
+ },
+ ],
+ illnessEpisodes: [
+ {
+ label: "Erkältung",
+ type: "INFECTION",
+ lifecycle: "ACUTE",
+ onsetAt: "2026-04-01T00:00:00.000Z",
+ resolvedAt: "2026-04-10T00:00:00.000Z",
+ },
+ ],
+ cycle: {
+ lastPeriodStart: "2026-04-20",
+ recentCycles: [],
+ observedCycleCount: 3,
+ averageCycleLengthDays: 27.5,
+ cycleLengthVariabilityDays: 0.5,
+ averagePeriodLengthDays: 5,
+ currentPhase: "LUTEAL",
+ },
+ mood: { avg: 3.8, count: 20 },
+ glp1: null,
+} as unknown as DoctorReportData;
+
+const RECORDS = {
+ allergies: [
+ {
+ id: "a1",
+ substance: "Penicillin",
+ category: "MEDICATION",
+ type: "ALLERGY",
+ status: "ACTIVE",
+ severity: "SEVERE",
+ reaction: "Rash",
+ onsetAt: null,
+ note: null,
+ },
+ ],
+ familyHistory: [
+ {
+ id: "f1",
+ relationship: "MOTHER",
+ condition: "Hypertension",
+ ageAtOnset: 50,
+ note: null,
+ },
+ ],
+} as never;
+
+function req(pathname: string): NextRequest {
+ return new NextRequest(`http://localhost${pathname}`);
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ count: 1,
+ resetAt: Date.now(),
+ } as never);
+ vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
+ vi.mocked(isModuleEnabled).mockResolvedValue(true);
+ vi.mocked(loadFhirContext).mockResolvedValue({
+ data: DATA,
+ identity: { insuranceNumber: "A123456780" },
+ germanAtc: false,
+ records: RECORDS,
+ });
+});
+
+interface CapabilityStatement {
+ resourceType: string;
+ implementation?: { description: string; url: string };
+ software?: { name: string; version: string };
+ description?: string;
+ rest: Array<{
+ resource: Array<{
+ type: string;
+ interaction?: Array<{ code: string }>;
+ operation?: Array<{ name: string }>;
+ }>;
+ }>;
+}
+
+describe("GET /api/fhir/metadata — CapabilityStatement", () => {
+ it("describes the instance it speaks for (cpb-2)", async () => {
+ const res = await metadataGet(req("/api/fhir/metadata"));
+ const body = (await res.json()) as CapabilityStatement;
+ expect(body.resourceType).toBe("CapabilityStatement");
+ expect(body.description?.length).toBeGreaterThan(0);
+ expect(body.software?.name).toBe("HealthLog");
+ expect(body.software?.version.length).toBeGreaterThan(0);
+ expect(body.implementation?.description.length).toBeGreaterThan(0);
+ expect(body.implementation?.url).toBe("http://localhost/api/fhir");
+ });
+
+ it("describes Patient exactly once, carrying the operation (cpb-9)", async () => {
+ const res = await metadataGet(req("/api/fhir/metadata"));
+ const body = (await res.json()) as CapabilityStatement;
+ const resources = body.rest[0].resource;
+ const types = resources.map((r) => r.type);
+ expect(types.filter((t) => t === "Patient")).toHaveLength(1);
+ expect(new Set(types).size).toBe(types.length);
+ const patient = resources.find((r) => r.type === "Patient");
+ expect(patient?.operation?.[0].name).toBe("everything");
+ expect(patient?.interaction?.map((i) => i.code)).toEqual([
+ "read",
+ "search-type",
+ ]);
+ });
+
+ it("advertises no interaction without a route behind it", async () => {
+ const res = await metadataGet(req("/api/fhir/metadata"));
+ const body = (await res.json()) as CapabilityStatement;
+ const root = path.join(process.cwd(), "src/app/api/fhir");
+
+ for (const resource of body.rest[0].resource) {
+ for (const { code } of resource.interaction ?? []) {
+ const file =
+ code === "read"
+ ? path.join(root, resource.type, "[id]", "route.ts")
+ : path.join(root, resource.type, "route.ts");
+ expect(existsSync(file), `${resource.type} ${code} → ${file}`).toBe(
+ true,
+ );
+ }
+ for (const { name } of resource.operation ?? []) {
+ const file = path.join(root, resource.type, `$${name}`, "route.ts");
+ expect(existsSync(file), `${resource.type} $${name} → ${file}`).toBe(
+ true,
+ );
+ }
+ }
+ // …and the catalogue the statement derives from is the routed set.
+ expect(body.rest[0].resource.map((r) => r.type)).toEqual([
+ ...FHIR_REST_RESOURCE_TYPES,
+ ]);
+ });
+
+ it("advertises no write interaction", async () => {
+ const res = await metadataGet(req("/api/fhir/metadata"));
+ const body = (await res.json()) as CapabilityStatement;
+ const codes = body.rest[0].resource.flatMap((r) =>
+ (r.interaction ?? []).map((i) => i.code),
+ );
+ for (const code of codes) {
+ expect(["read", "search-type"]).toContain(code);
+ }
+ });
+});
+
+describe("GET /api/fhir/{type}/{id} — read", () => {
+ it("serves the resource the searchset fullUrl points at", async () => {
+ const res = await observationRead(req("/api/fhir/Observation/obs-1"), {
+ params: Promise.resolve({ id: "obs-1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("application/fhir+json");
+ const body = (await res.json()) as { resourceType: string; id: string };
+ expect(body.resourceType).toBe("Observation");
+ expect(body.id).toBe("obs-1");
+ });
+
+ it("answers an id the record does not hold with a not-found OperationOutcome", async () => {
+ const res = await observationRead(req("/api/fhir/Observation/obs-999"), {
+ params: Promise.resolve({ id: "obs-999" }),
+ });
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as {
+ resourceType: string;
+ issue: Array<{ code: string }>;
+ };
+ expect(body.resourceType).toBe("OperationOutcome");
+ expect(body.issue[0].code).toBe("not-found");
+ });
+
+ it("reads the Patient", async () => {
+ const res = await patientRead(req("/api/fhir/Patient/patient-1"), {
+ params: Promise.resolve({ id: "patient-1" }),
+ });
+ const body = (await res.json()) as { resourceType: string };
+ expect(body.resourceType).toBe("Patient");
+ });
+
+ it("reads the Coverage", async () => {
+ const res = await coverageRead(req("/api/fhir/Coverage/coverage-1"), {
+ params: Promise.resolve({ id: "coverage-1" }),
+ });
+ const body = (await res.json()) as { resourceType: string };
+ expect(body.resourceType).toBe("Coverage");
+ });
+});
+
+describe("GET /api/fhir/Patient/$everything", () => {
+ it("returns every resource family the document bundle carries", async () => {
+ const res = await everythingGet(
+ req("/api/fhir/Patient/$everything?_count=200"),
+ );
+ expect(res.status).toBe(200);
+ const bundle = (await res.json()) as {
+ type: string;
+ total: number;
+ entry: Array<{ fullUrl: string; resource: { resourceType: string } }>;
+ };
+ expect(bundle.type).toBe("searchset");
+
+ const document = buildFhirDocumentBundle(
+ DATA,
+ { insuranceNumber: "A123456780" },
+ FIXED_NOW,
+ { germanAtc: false },
+ RECORDS,
+ );
+ const expected = new Set(
+ document.entry.map((e) => e.resource.resourceType),
+ );
+ const served = new Set(bundle.entry.map((e) => e.resource.resourceType));
+ expect(served).toEqual(expected);
+ expect(bundle.total).toBe(document.entry.length);
+
+ // The families the operation used to drop.
+ for (const type of [
+ "Composition",
+ "Device",
+ "Condition",
+ "Encounter",
+ "AllergyIntolerance",
+ "FamilyMemberHistory",
+ "DiagnosticReport",
+ "Coverage",
+ ]) {
+ expect(served.has(type), `missing ${type}`).toBe(true);
+ }
+ // …including the cycle Observations, which share a resourceType with the
+ // vitals and would hide inside the set check above.
+ const ids = bundle.entry.map(
+ (e) => (e.resource as unknown as { id: string }).id,
+ );
+ expect(ids.some((id) => id.startsWith("obs-cycle-"))).toBe(true);
+ });
+
+ it("keeps the entry identities the references resolve against", async () => {
+ const res = await everythingGet(
+ req("/api/fhir/Patient/$everything?_count=200"),
+ );
+ const bundle = (await res.json()) as {
+ entry: Array<{
+ fullUrl: string;
+ search: { mode: string };
+ resource: { subject?: { reference?: string } };
+ }>;
+ };
+ const urls = new Set(bundle.entry.map((e) => e.fullUrl));
+ for (const entry of bundle.entry) {
+ expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true);
+ expect(entry.search.mode).toBe("match");
+ const subject = entry.resource.subject?.reference;
+ if (subject) expect(urls.has(subject)).toBe(true);
+ }
+ });
+});
diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts
index f869a3e75..f56c71cbb 100644
--- a/src/app/api/fhir/__tests__/module-gate.test.ts
+++ b/src/app/api/fhir/__tests__/module-gate.test.ts
@@ -47,9 +47,12 @@ vi.mock("@/lib/fhir/rest", async (importOriginal) => {
});
vi.mock("@/lib/fhir/resources", () => ({
GERMAN_ATC_DEFAULT_LOCALES: ["de"],
- patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })),
+ PATIENT_RESOURCE_ID: "patient-1",
+ patientResource: vi.fn(() => ({ resourceType: "Patient", id: "patient-1" })),
coverageResource: vi.fn(() => null),
observationsFromReportData: vi.fn(() => []),
+ cycleObservationsFromReportData: vi.fn(() => []),
+ conditionsFromReportData: vi.fn(() => ({ conditions: [], encounters: [] })),
medicationStatementsFromReportData: vi.fn(() => []),
medicationAdministrationsFromReportData: vi.fn(() => []),
}));
@@ -63,10 +66,16 @@ vi.mock("next/headers", () => ({
}));
import { GET as patientGet } from "../Patient/route";
+import { GET as coverageGet } from "../Coverage/route";
+import { GET as patientRead } from "../Patient/[id]/route";
+import { GET as coverageRead } from "../Coverage/[id]/route";
+import { GET as observationRead } from "../Observation/[id]/route";
+import { GET as medStatementRead } from "../MedicationStatement/[id]/route";
+import { GET as medAdminRead } from "../MedicationAdministration/[id]/route";
import { GET as observationGet } from "../Observation/route";
import { GET as medStatementGet } from "../MedicationStatement/route";
import { GET as medAdminGet } from "../MedicationAdministration/route";
-import { GET as everythingGet } from "../$everything/route";
+import { GET as everythingGet } from "../Patient/$everything/route";
import { GET as metadataGet } from "../metadata/route";
import { getSession } from "@/lib/auth/session";
@@ -95,15 +104,42 @@ const SESSION_OK = {
user: { id: "user-1", username: "tester", role: "USER" as const },
};
-/** Every data route under `/api/fhir`, by the path a caller would hit. */
+/**
+ * Every data route under `/api/fhir`, by the path a caller would hit. The
+ * `/{type}/{id}` reads are here too: a route that 403s on search and serves on
+ * read would leak the same record through the other door.
+ */
const DATA_ROUTES: ReadonlyArray<
[string, (req: NextRequest) => Promise]
> = [
["Patient", patientGet],
+ ["Coverage", coverageGet],
["Observation", observationGet],
["MedicationStatement", medStatementGet],
["MedicationAdministration", medAdminGet],
- ["$everything", everythingGet],
+ ["Patient/$everything", everythingGet],
+];
+
+/**
+ * The `/{type}/{id}` reads. Same gate, different success shape (a bare
+ * resource, not a Bundle) — a route that 403s on search and serves on read
+ * would leak the same record through the other door.
+ */
+function idCtx(id: string) {
+ return { params: Promise.resolve({ id }) };
+}
+
+const READ_ROUTES: ReadonlyArray<
+ [string, (req: NextRequest) => Promise]
+> = [
+ ["Patient/patient-1", (req) => patientRead(req, idCtx("patient-1"))],
+ ["Coverage/coverage-1", (req) => coverageRead(req, idCtx("coverage-1"))],
+ ["Observation/obs-1", (req) => observationRead(req, idCtx("obs-1"))],
+ ["MedicationStatement/med-1", (req) => medStatementRead(req, idCtx("med-1"))],
+ [
+ "MedicationAdministration/medadmin-1",
+ (req) => medAdminRead(req, idCtx("medadmin-1")),
+ ],
];
function req(path: string): NextRequest {
@@ -124,9 +160,18 @@ beforeEach(() => {
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(loadFhirContext).mockResolvedValue({
- data: {} as never,
+ data: {
+ period: {
+ days: 90,
+ since: "2026-02-02T00:00:00.000Z",
+ start: "2026-02-02T00:00:00.000Z",
+ end: "2026-05-03T00:00:00.000Z",
+ },
+ patient: {},
+ } as never,
identity: { insuranceNumber: "A123456789" },
germanAtc: false,
+ records: {},
});
});
@@ -168,10 +213,37 @@ describe("/api/fhir/* — doctorReport module gate", () => {
});
});
+ describe.each(READ_ROUTES)("GET /api/fhir/%s", (path, handler) => {
+ it("403s with module.disabled when the module is off", async () => {
+ setDoctorReportModule(false);
+
+ const res = await handler(req(path));
+ expect(res.status).toBe(403);
+ const body = (await res.json()) as {
+ meta?: { errorCode?: string; module?: string };
+ };
+ expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
+ expect(body.meta?.module).toBe("doctorReport");
+ expect(loadFhirContext).not.toHaveBeenCalled();
+ expect(JSON.stringify(body)).not.toContain("A123456789");
+ });
+
+ it("reaches the record when the module is on", async () => {
+ setDoctorReportModule(true);
+
+ const res = await handler(req(path));
+ // The emitters are stubbed empty here, so the honest answer is a
+ // `not-found` OperationOutcome — what matters is that the gate did not
+ // refuse and the aggregate was assembled.
+ expect(res.status).not.toBe(403);
+ expect(loadFhirContext).toHaveBeenCalledWith("user-1");
+ });
+ });
+
it("leaves the static CapabilityStatement reachable while the module is off", async () => {
// Server metadata, no user data — the one FHIR route that stays open.
setDoctorReportModule(false);
- const res = await metadataGet();
+ const res = await metadataGet(req("metadata"));
expect(res.status).toBe(200);
const body = (await res.json()) as { resourceType: string };
expect(body.resourceType).toBe("CapabilityStatement");
diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts
index 0a061e8da..a5e585096 100644
--- a/src/app/api/fhir/__tests__/route.test.ts
+++ b/src/app/api/fhir/__tests__/route.test.ts
@@ -78,6 +78,7 @@ beforeEach(() => {
data: {} as never,
identity: { insuranceNumber: null },
germanAtc: false,
+ records: {},
});
});
diff --git a/src/app/api/fhir/metadata/route.ts b/src/app/api/fhir/metadata/route.ts
index 341a6c2fc..2bd4587fb 100644
--- a/src/app/api/fhir/metadata/route.ts
+++ b/src/app/api/fhir/metadata/route.ts
@@ -1,12 +1,22 @@
/**
* GET /api/fhir/metadata — FHIR R4 `CapabilityStatement`.
*
- * Declares the read-only REST face: the resource types served, the search
- * parameters honoured (`_count`, `_offset`), the `$everything` operation, and
- * the `application/fhir+json` format. Static — no per-user data — but still
- * gated behind the `fhir:read` scope so the whole `/api/fhir` tree answers
+ * Declares the read-only REST face: the resource types served, the `read` +
+ * `search-type` interactions on each, the search parameters honoured
+ * (`_count`, `_offset`), the `Patient-everything` operation, and the
+ * `application/fhir+json` format. Static — no per-user data — but still gated
+ * behind the `fhir:read` scope so the whole `/api/fhir` tree answers
* uniformly. Read-only: no write interactions are advertised.
+ *
+ * Every entry here is backed by a route. `FHIR_REST_RESOURCE_TYPES` is the one
+ * catalogue the resource entries derive from, and each type in it has both a
+ * `/{type}` search route and a `/{type}/{id}` read route; the operation is
+ * routed at `/api/fhir/Patient/$everything`. A statement that advertises an
+ * interaction a client cannot reach is worse than one that stays quiet.
*/
+import type { NextRequest } from "next/server";
+
+import packageJson from "../../../../../package.json";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -16,15 +26,32 @@ import {
fhirJsonResponse,
} from "@/lib/fhir/rest";
-export const GET = apiHandler(async () => {
+export const GET = apiHandler(async (request: NextRequest) => {
await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.metadata.read" } });
+ // Same resolution order as `/api/version`: the image build arg wins so a
+ // deployed instance names its release, with the package version as the
+ // local-dev fallback.
+ const version =
+ process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
+
const capability = {
resourceType: "CapabilityStatement",
status: "active",
date: new Date().toISOString(),
kind: "instance",
+ description:
+ "Read-only HL7 FHIR R4 face over a single HealthLog account's own health record.",
+ software: { name: "HealthLog", version },
+ // `cpb-2` — a `kind: "instance"` statement SHALL describe the instance it
+ // speaks for. The URL is the deployment answering this request, not a
+ // hardcoded host: a self-hosted instance is reachable at its own origin
+ // and nowhere else.
+ implementation: {
+ description: "HealthLog self-hosted instance",
+ url: new URL("/api/fhir", request.nextUrl.origin).toString(),
+ },
fhirVersion: "4.0.1",
format: ["application/fhir+json"],
rest: [
@@ -32,26 +59,28 @@ export const GET = apiHandler(async () => {
mode: "server",
documentation:
"Read-only access to the authenticated user's own health record.",
- resource: [
- ...FHIR_REST_RESOURCE_TYPES.map((type) => ({
- type,
- interaction: [{ code: "read" }, { code: "search-type" }],
- searchParam: FHIR_SEARCH_PARAMS.map((name) => ({
- name,
- type: "number",
- })),
+ // `cpb-9` — one entry per resource type. The Patient entry carries the
+ // `$everything` operation alongside its interactions rather than
+ // appearing a second time.
+ resource: FHIR_REST_RESOURCE_TYPES.map((type) => ({
+ type,
+ interaction: [{ code: "read" }, { code: "search-type" }],
+ searchParam: FHIR_SEARCH_PARAMS.map((name) => ({
+ name,
+ type: "number",
})),
- {
- type: "Patient",
- operation: [
- {
- name: "everything",
- definition:
- "http://hl7.org/fhir/OperationDefinition/Patient-everything",
- },
- ],
- },
- ],
+ ...(type === "Patient"
+ ? {
+ operation: [
+ {
+ name: "everything",
+ definition:
+ "http://hl7.org/fhir/OperationDefinition/Patient-everything",
+ },
+ ],
+ }
+ : {}),
+ })),
},
],
};
diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts
index 0f3f4e5c3..970ca750a 100644
--- a/src/app/api/meta/capabilities/__tests__/route.test.ts
+++ b/src/app/api/meta/capabilities/__tests__/route.test.ts
@@ -59,7 +59,10 @@ import {
FHIR_SEARCH_PARAMS,
} from "@/lib/fhir/rest";
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
-import { exportSectionsSchema } from "@/lib/validations/health-record-export";
+import {
+ ALL_LEAF_IDS,
+ REPORT_GROUP_ORDER,
+} from "@/lib/report-selection/catalogue";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -92,8 +95,10 @@ type CapabilitiesBody = {
share: {
supported: boolean;
maxDays: number;
- fhirApi: boolean;
- sections: string[];
+ reportDownload: string[];
+ selectionVersion: number;
+ groups: string[];
+ leaves: string[];
};
};
};
@@ -175,11 +180,14 @@ describe("GET /api/meta/capabilities — drift guards", () => {
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.share.supported).toBe(true);
expect(body.data.share.maxDays).toBe(SHARE_LINK_MAX_DAYS);
- // The share serves the rendered record view only; no `/api/fhir/*` route
- // honours a share token yet, so the share→FHIR face is advertised off.
- expect(body.data.share.fhirApi).toBe(false);
- expect([...body.data.share.sections].sort()).toEqual(
- Object.keys(exportSectionsSchema.shape).sort(),
+ // The record IS reachable in machine form now — as a download under the
+ // token, not as a bearer API. The old `fhirApi: false` described a face
+ // that was never built.
+ expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]);
+ expect(body.data.share.selectionVersion).toBe(2);
+ expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]);
+ expect([...body.data.share.leaves].sort()).toEqual(
+ [...ALL_LEAF_IDS].sort(),
);
});
diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts
index 57ae2e92b..c38b61431 100644
--- a/src/app/api/share-links/__tests__/route.test.ts
+++ b/src/app/api/share-links/__tests__/route.test.ts
@@ -41,7 +41,10 @@ vi.mock("next/headers", () => ({
import { POST, GET } from "../route";
import { DELETE } from "../[id]/route";
-import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
+import {
+ selectionToBlob,
+ selectionFromLeaves,
+} from "@/lib/report-selection/selection";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -81,6 +84,8 @@ function storedRow(overrides: Record = {}) {
revokedAt: null,
lastAccessAt: null,
accessCount: 0,
+ documentOnly: false,
+ sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT"])),
_count: { documents: 0 },
...overrides,
};
@@ -277,7 +282,7 @@ describe("POST /api/share-links — create", () => {
postReq(
validBody({
documentOnly: true,
- sections: { vitals: { bp: true }, labs: true },
+ selection: { v: 2, leaves: ["BLOOD_PRESSURE_SYS", "LAB_RESULTS"] },
documentIds: ["doc-a"],
}),
),
@@ -289,9 +294,9 @@ describe("POST /api/share-links — create", () => {
// The retired FHIR scope columns are never written at all.
expect(createArg.data).not.toHaveProperty("resourceTypes");
expect(createArg.data).not.toHaveProperty("allowFhirApi");
- // Sections persist as an explicit all-OFF blob (distinct from `{}` = full
- // record defaults). The clinician view reads this as "no report".
- expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS);
+ // The scope persists as an explicit EMPTY leaf list. The clinician view
+ // reads that as "no report" and never calls the aggregator.
+ expect(createArg.data.sectionsJson).toEqual({ v: 2, leaves: [] });
// v1.28.16 — the frozen documents-only flag is persisted so the view path
// gates on the column, not on inferring "all sections off". This is what a
// future report section can never re-open.
@@ -362,3 +367,120 @@ describe("DELETE /api/share-links/[id] — revoke", () => {
expect(res.status).toBe(404);
});
});
+
+/**
+ * The frozen scope, and the two things a share link may never carry.
+ *
+ * Mutation checks:
+ * - drop the `SHARE_LINK_FORBIDDEN_LEAVES` check in the route → "refuses the
+ * insurance leaf by name" goes red.
+ * - drop `RETIRED_FHIR_SCOPE_FIELDS` from the create response → "keeps the
+ * retired scope fields on the response" goes red.
+ * - make `needsReselection` always false → "flags a link whose frozen scope
+ * predates the selection model" goes red.
+ */
+describe("POST /api/share-links — the frozen scope", () => {
+ it("persists exactly the leaves the owner chose", async () => {
+ vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
+ storedRow() as never,
+ );
+ const res = await POST(
+ postReq(
+ validBody({
+ selection: { v: 2, leaves: ["LAB_RESULTS", "WEIGHT"] },
+ }),
+ ),
+ );
+ expect(res.status).toBe(201);
+ const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
+ .calls[0][0];
+ // Canonical order, not the caller's.
+ expect(createArg.data.sectionsJson).toEqual({
+ v: 2,
+ leaves: ["WEIGHT", "LAB_RESULTS"],
+ });
+ });
+
+ it("refuses the insurance leaf by name", async () => {
+ // The share view has never decrypted the insurance number. Refusing the
+ // leaf at create makes that structural: there is no path by which a link
+ // could later carry it.
+ const res = await POST(
+ postReq(validBody({ selection: { v: 2, leaves: ["INSURANCE"] } })),
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("INSURANCE");
+ expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
+ });
+
+ it("refuses a leaf id this build does not know", async () => {
+ const res = await POST(
+ postReq(validBody({ selection: { v: 2, leaves: ["SOMETHING_NEW"] } })),
+ );
+ expect(res.status).toBe(422);
+ expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
+ });
+
+ it("mints an empty scope when the body names none", async () => {
+ vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
+ storedRow() as never,
+ );
+ const res = await POST(postReq(validBody()));
+ expect(res.status).toBe(201);
+ const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
+ .calls[0][0];
+ expect(createArg.data.sectionsJson).toEqual({ v: 2, leaves: [] });
+ });
+
+ it("keeps the retired scope fields on the response", async () => {
+ // Response-only, serving constants. The request refuses them and the
+ // columns are gone; they stay here until the native client confirms it
+ // does not decode them.
+ vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
+ storedRow() as never,
+ );
+ const res = await POST(
+ postReq(validBody({ selection: { v: 2, leaves: ["WEIGHT"] } })),
+ );
+ const body = (await res.json()) as {
+ data: { allowFhirApi: boolean; resourceTypes: string[] };
+ };
+ expect(body.data.allowFhirApi).toBe(false);
+ expect(body.data.resourceTypes).toEqual([]);
+ });
+});
+
+describe("GET /api/share-links — the pick-again state", () => {
+ it("flags a link whose frozen scope predates the selection model", async () => {
+ vi.mocked(prisma.clinicianShareLink.findMany).mockResolvedValue([
+ storedRow({
+ id: "legacy",
+ revokedAt: new Date(),
+ sectionsJson: { bp: true, weight: true, pulse: true },
+ }),
+ storedRow({ id: "current" }),
+ storedRow({ id: "docs", documentOnly: true, sectionsJson: {} }),
+ ] as never);
+ const res = await GET();
+ const body = (await res.json()) as {
+ data: {
+ shareLinks: Array<{
+ id: string;
+ needsReselection: boolean;
+ allowFhirApi: boolean;
+ resourceTypes: string[];
+ }>;
+ };
+ };
+ const byId = new Map(body.data.shareLinks.map((l) => [l.id, l]));
+ expect(byId.get("legacy")!.needsReselection).toBe(true);
+ expect(byId.get("current")!.needsReselection).toBe(false);
+ // A documents-only link carried no report scope to begin with, so it was
+ // never revoked and is not asked to pick again.
+ expect(byId.get("docs")!.needsReselection).toBe(false);
+ // The retired fields ride the list response too.
+ expect(byId.get("current")!.allowFhirApi).toBe(false);
+ expect(byId.get("current")!.resourceTypes).toEqual([]);
+ });
+});
diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx
index f77f86bab..2650ffd25 100644
--- a/src/components/clinician/__tests__/clinician-view.test.tsx
+++ b/src/components/clinician/__tests__/clinician-view.test.tsx
@@ -11,7 +11,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import { ClinicianView } from "../clinician-view";
import { getServerTranslator } from "@/lib/i18n/server-translator";
-import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
+import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
@@ -76,7 +77,7 @@ function render(
label="Cardiology clinic"
expiresAt="2026-03-01T00:00:00.000Z"
report={report}
- sections={{ ...DEFAULT_DOCTOR_REPORT_PREFS }}
+ selection={selectionFromLeaves(ALL_LEAF_IDS)}
{...extra}
/>,
);
@@ -100,10 +101,12 @@ describe("", () => {
it("renders clinical vitals from the scoped report", () => {
const html = render(makeReport());
- expect(html).toContain("Vital signs");
- // The measurement-type enum renders as its localised label, not the raw
- // enum string.
- expect(html).toContain("Body weight");
+ // The measurement rows are grouped the way the selection panel groups
+ // them, so the page reads in the same order as the PDF.
+ expect(html).toContain("Body measurements");
+ // The measurement-type enum renders as the SAME localised label the rest
+ // of the app uses, not the raw enum string and not a second vocabulary.
+ expect(html).toContain(">Weight<");
expect(html).not.toContain("WEIGHT");
});
diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx
index b6b326e09..696a66755 100644
--- a/src/components/settings/__tests__/disable-coach-card.test.tsx
+++ b/src/components/settings/__tests__/disable-coach-card.test.tsx
@@ -75,6 +75,7 @@ function buildUser(disableCoach: boolean): AuthUser {
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
+ reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
};
diff --git a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx
deleted file mode 100644
index 24c5c298e..000000000
--- a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Settings → Gesundheitsakte: the practice name is remembered between reports.
- *
- * `User.lastReportPracticeName` is persisted by the export route after a
- * successful generation and rides the `/api/auth/me` payload. This suite pins
- * the read half: the panel opens pre-filled with the last-used name instead of
- * asking for the clinic name again on every report.
- *
- * SSR-only, per the project convention for this surface (node environment,
- * no `@testing-library/react`). The seed runs during render — a `useState`
- * initializer would read `undefined` while `useAuth` is still resolving and
- * stick at empty — so the static markup already carries the seeded value.
- *
- * Mutation check: revert the seeding block in the panel and the first case
- * goes red.
- */
-import { describe, expect, it, vi } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-
-import type { AuthUser } from "@/hooks/use-auth";
-
-const authSpy = vi.fn<() => { user: AuthUser | null }>();
-vi.mock("@/hooks/use-auth", async () => {
- const actual =
- await vi.importActual(
- "@/hooks/use-auth",
- );
- return { ...actual, useAuth: () => authSpy() };
-});
-
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({ invalidateQueries: vi.fn() }),
-}));
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { HealthRecordExportPanel } from "../health-record-export-panel";
-
-function buildUser(lastReportPracticeName: string | null): AuthUser {
- return {
- id: "user-1",
- username: "user",
- email: null,
- role: "USER",
- heightCm: null,
- dateOfBirth: null,
- gender: null,
- timezone: "Europe/Berlin",
- onboardingCompletedAt: null,
- onboardingTourCompleted: true,
- onboardingTourProgress: null,
- avatarUrl: null,
- glucoseUnit: null,
- unitPreference: "metric",
- timeFormat: "AUTO",
- dateFormat: "AUTO",
- disableCoach: false,
- fullName: null,
- insurerName: null,
- insurerIkNumber: null,
- insuranceNumber: null,
- lastReportPracticeName,
- cycleTrackingEnabled: false,
- modules: {},
- } as AuthUser;
-}
-
-function renderPanel(user: AuthUser | null): string {
- authSpy.mockReturnValue({ user });
- return renderToStaticMarkup(
-
-
- ,
- );
-}
-
-function practiceInputValue(html: string): string | null {
- const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0];
- if (!input) return null;
- return input.match(/value="([^"]*)"/)?.[1] ?? "";
-}
-
-describe(" — remembered practice name", () => {
- it("pre-fills the input with the last-used practice name", () => {
- const html = renderPanel(buildUser("Sample Practice"));
- expect(practiceInputValue(html)).toBe("Sample Practice");
- });
-
- it("leaves the input empty when no name was ever remembered", () => {
- const html = renderPanel(buildUser(null));
- expect(practiceInputValue(html)).toBe("");
- });
-
- it("leaves the input empty while the auth payload is still resolving", () => {
- const html = renderPanel(null);
- expect(practiceInputValue(html)).toBe("");
- });
-});
diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx
index a77475675..c30bad4bd 100644
--- a/src/components/settings/__tests__/modules-section.test.tsx
+++ b/src/components/settings/__tests__/modules-section.test.tsx
@@ -108,6 +108,7 @@ function buildUser(
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
+ reportSelection: null,
cycleTrackingEnabled: false,
modules,
moduleAvailability: {},
diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx
new file mode 100644
index 000000000..830e1762d
--- /dev/null
+++ b/src/components/settings/__tests__/report-selection-panel.test.tsx
@@ -0,0 +1,234 @@
+/**
+ * Settings → Gesundheitsakte: what the export panel opens with.
+ *
+ * Two things are seeded from `/api/auth/me` during render — the practice name
+ * and the saved selection. The seed runs during render because a `useState`
+ * initialiser would read `undefined` while `useAuth` is still resolving and
+ * stick, and it is gated on the user id so a late re-resolve never clobbers
+ * edits in flight.
+ *
+ * The three panel states are the load-bearing part. First run must open the
+ * picker EXPANDED with the named template applied and the fenced tier visibly
+ * empty, so the user presses Generate having seen twelve named groups; a
+ * repeat run must open collapsed carrying the owner's own last scope. A
+ * template silently applied behind a collapsed disclosure would be opt-out
+ * wearing a hat.
+ *
+ * SSR-only, per the project convention for this surface.
+ *
+ * Mutation checks:
+ * - remove the seeding block → "pre-fills the input" and "restores the saved
+ * selection" go red.
+ * - start `pickerOpen` at `false` → "opens expanded on the first run" goes
+ * red.
+ * - add a fenced leaf to `STANDARD_TEMPLATE_LEAVES` → "checks no fenced leaf
+ * on the first run" goes red.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import type { AuthUser } from "@/hooks/use-auth";
+
+const authSpy = vi.fn<() => { user: AuthUser | null }>();
+vi.mock("@/hooks/use-auth", async () => {
+ const actual =
+ await vi.importActual(
+ "@/hooks/use-auth",
+ );
+ return { ...actual, useAuth: () => authSpy() };
+});
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { HealthRecordExportPanel } from "../report-selection/selection-panel";
+
+function buildUser(lastReportPracticeName: string | null): AuthUser {
+ return {
+ id: "user-1",
+ username: "user",
+ email: null,
+ role: "USER",
+ heightCm: null,
+ dateOfBirth: null,
+ gender: null,
+ timezone: "Europe/Berlin",
+ onboardingCompletedAt: null,
+ onboardingTourCompleted: true,
+ onboardingTourProgress: null,
+ avatarUrl: null,
+ glucoseUnit: null,
+ unitPreference: "metric",
+ timeFormat: "AUTO",
+ dateFormat: "AUTO",
+ disableCoach: false,
+ fullName: null,
+ insurerName: null,
+ insurerIkNumber: null,
+ insuranceNumber: null,
+ lastReportPracticeName,
+ reportSelection: null,
+ cycleTrackingEnabled: false,
+ modules: {},
+ } as AuthUser;
+}
+
+function withSavedSelection(user: AuthUser, leaves: string[]): AuthUser {
+ return {
+ ...user,
+ reportSelection: {
+ v: 2,
+ leaves,
+ format: "fhir",
+ rangeDays: 180,
+ includeCharts: false,
+ },
+ } as AuthUser;
+}
+
+/**
+ * Whether a leaf row renders as checked. Only the fenced tier and an expanded
+ * group render leaf rows at all — a collapsed group shows its count chip
+ * instead, which is the point of the two-interaction repeat path.
+ */
+function leafChecked(html: string, leaf: string): boolean {
+ const row = html.match(
+ new RegExp(`data-testid="report-leaf-${leaf}"[\\s\\S]*?`),
+ )?.[0];
+ if (!row) return false;
+ return /data-state="checked"/.test(row);
+}
+
+/** The "n/total" chip a collapsed group row carries. */
+function groupChip(html: string, group: string): string | null {
+ const row = html.match(
+ new RegExp(`data-testid="report-group-row-${group}"[\\s\\S]*?
`),
+ )?.[0];
+ return row?.match(/data-slot="tag-chip"[^>]*>([^<]*))?.[1] ?? null;
+}
+
+function renderPanel(user: AuthUser | null): string {
+ authSpy.mockReturnValue({ user });
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+function practiceInputValue(html: string): string | null {
+ const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0];
+ if (!input) return null;
+ return input.match(/value="([^"]*)"/)?.[1] ?? "";
+}
+
+describe(" — remembered practice name", () => {
+ it("pre-fills the input with the last-used practice name", () => {
+ const html = renderPanel(buildUser("Sample Practice"));
+ expect(practiceInputValue(html)).toBe("Sample Practice");
+ });
+
+ it("leaves the input empty when no name was ever remembered", () => {
+ const html = renderPanel(buildUser(null));
+ expect(practiceInputValue(html)).toBe("");
+ });
+
+ it("leaves the input empty while the auth payload is still resolving", () => {
+ const html = renderPanel(null);
+ expect(practiceInputValue(html)).toBe("");
+ });
+});
+
+describe(" — the first run", () => {
+ it("opens the picker expanded and names the template it applied", () => {
+ const html = renderPanel(buildUser(null));
+ expect(html).toContain('data-testid="health-record-included-data-panel"');
+ expect(html).toContain('data-testid="report-scope-picker"');
+ expect(html).toContain("Standard doctor's report");
+ });
+
+ it("counts the template's leaves per group, and no others", () => {
+ const html = renderPanel(buildUser(null));
+ // Identity: both leaves. Vitals: the three classic ones out of seven.
+ // Body: weight and BMI out of twelve. Nothing at all from the groups the
+ // template leaves alone.
+ expect(groupChip(html, "identity")).toBe("2/2");
+ expect(groupChip(html, "vitals")).toBe("3/7");
+ expect(groupChip(html, "body")).toBe("2/12");
+ expect(groupChip(html, "labs")).toBe("1/1");
+ expect(groupChip(html, "medications")).toBe("2/4");
+ expect(groupChip(html, "history")).toBe("2/2");
+ expect(groupChip(html, "cardio")).toBe("0/14");
+ expect(groupChip(html, "activity")).toBe("0/8");
+ expect(groupChip(html, "sleepRecovery")).toBe("0/16");
+ expect(groupChip(html, "mobility")).toBe("0/11");
+ expect(groupChip(html, "environment")).toBe("0/4");
+ });
+
+ it("leaves every fenced leaf unchecked and renders the tier", () => {
+ const html = renderPanel(buildUser(null));
+ expect(html).toContain('data-testid="report-sensitive-tier"');
+ for (const leaf of [
+ "MOOD",
+ "CYCLE",
+ "FAMILY_HISTORY",
+ "PHQ9_SCORE",
+ "GAD7_SCORE",
+ "WHO5_SCORE",
+ "SCI_SCORE",
+ ]) {
+ expect(leafChecked(html, leaf), leaf).toBe(false);
+ }
+ });
+
+ it("gives the fenced tier no group checkbox", () => {
+ const html = renderPanel(buildUser(null));
+ expect(html).toContain('data-testid="report-group-check-vitals"');
+ expect(html).not.toContain('data-testid="report-group-check-sensitive"');
+ });
+
+ it("states the scope in one line", () => {
+ const html = renderPanel(buildUser(null));
+ expect(html).toContain('data-testid="report-scope-summary"');
+ expect(html).toContain("14 entries from 7 areas");
+ });
+});
+
+describe(" — a repeat run", () => {
+ it("restores the saved selection and collapses the picker", () => {
+ const html = renderPanel(
+ withSavedSelection(buildUser("Sample Practice"), [
+ "RESTING_HEART_RATE",
+ "MOOD",
+ ]),
+ );
+ expect(html).not.toContain(
+ 'data-testid="health-record-included-data-panel"',
+ );
+ // The scope line still says what will be in the document, and it names the
+ // fenced inclusion rather than folding it into the count.
+ expect(html).toContain("2 entries from 2 areas");
+ expect(html).toContain("incl. Mood");
+ });
+
+ it("restores the saved format and range", () => {
+ const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"]));
+ // FHIR was saved, so the practice-name line (PDF only) is not rendered.
+ expect(practiceInputValue(html)).toBeNull();
+ expect(html).toContain('
) : null}
diff --git a/src/lib/__tests__/doctor-report-module-backstop.test.ts b/src/lib/__tests__/doctor-report-module-backstop.test.ts
index a9ace3b97..2e3247f22 100644
--- a/src/lib/__tests__/doctor-report-module-backstop.test.ts
+++ b/src/lib/__tests__/doctor-report-module-backstop.test.ts
@@ -37,6 +37,10 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
import { collectDoctorReportData } from "../doctor-report-data";
import { resolveModuleMap, MODULE_KEYS } from "@/lib/modules/gate";
+import {
+ EMPTY_REPORT_SELECTION,
+ selectionFromLeaves,
+} from "@/lib/report-selection/selection";
const RANGE = {
start: new Date("2026-01-01T00:00:00.000Z"),
@@ -61,6 +65,7 @@ describe("collectDoctorReportData — doctorReport backstop", () => {
collectDoctorReportData(
"u1",
RANGE,
+ EMPTY_REPORT_SELECTION,
// Injected rather than read, so the refusal is attributable to the
// module state and not to a DB stub.
{
@@ -79,9 +84,9 @@ describe("collectDoctorReportData — doctorReport backstop", () => {
moduleMap({ doctorReport: false }) as never,
);
- await expect(collectDoctorReportData("u1", RANGE)).rejects.toThrow(
- /doctorReport/,
- );
+ await expect(
+ collectDoctorReportData("u1", RANGE, EMPTY_REPORT_SELECTION),
+ ).rejects.toThrow(/doctorReport/);
expect(resolveModuleMap).toHaveBeenCalledWith("u1");
});
@@ -92,7 +97,7 @@ describe("collectDoctorReportData — doctorReport backstop", () => {
// stub, never the backstop, or the "off" assertions above would pass
// trivially for a function that always throws.
await expect(
- collectDoctorReportData("u1", RANGE, {
+ collectDoctorReportData("u1", RANGE, selectionFromLeaves(["WEIGHT"]), {
moduleMap: moduleMap() as never,
}),
).rejects.toThrow("DB touched");
diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts
index 38fdba169..ff2ca9606 100644
--- a/src/lib/__tests__/doctor-report-pdf-core.test.ts
+++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts
@@ -4,11 +4,10 @@ import {
buildDoctorReportPdfDocument,
renderDoctorReportPdfBytes,
sanitiseForPdf,
- DOCTOR_REPORT_VITAL_TYPES,
- DOCTOR_REPORT_TYPE_LABEL_KEYS,
- DOCTOR_REPORT_TYPE_UNIT_KEYS,
+ DOCTOR_REPORT_VITAL_GROUPS,
} from "../doctor-report-pdf-core";
import { measurementTypeEnum } from "../validations/measurement";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import type { DoctorReportData } from "../doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { getServerTranslator } from "../i18n/server-translator";
@@ -197,7 +196,7 @@ describe("renderDoctorReportPdfBytes", () => {
const text = await extractText(bytes);
// The sleep label and the hours-converted value both appear in the table.
// `num` renders one fixed fraction digit: 420 min → 7.0 h, 450 min → 7.5 h.
- expect(text).toContain("Sleep duration");
+ expect(text).toContain("Sleep");
expect(text).toContain("7.0 h");
expect(text).toContain("7.5 h");
// The raw minute value must NOT leak into the table.
@@ -321,27 +320,30 @@ describe("doctor-report pagination", () => {
// stale type map and silently dropped body composition while the browser
// PDF rendered them. These tests pin the contract.
describe("doctor-report-pdf-core type-map coverage", () => {
- it("exposes body composition (TOTAL_BODY_WATER, BONE_MASS) as vital types", () => {
- expect(DOCTOR_REPORT_VITAL_TYPES).toContain("TOTAL_BODY_WATER");
- expect(DOCTOR_REPORT_VITAL_TYPES).toContain("BONE_MASS");
+ it("puts body composition in the body group", () => {
+ const body = DOCTOR_REPORT_VITAL_GROUPS.find(
+ (g) => g.labelKey === "reportSelection.groupBody",
+ )!;
+ expect(body.types).toContain("TOTAL_BODY_WATER");
+ expect(body.types).toContain("BONE_MASS");
});
- it("provides a label key + unit for every vital type", () => {
- for (const type of DOCTOR_REPORT_VITAL_TYPES) {
- expect(DOCTOR_REPORT_TYPE_LABEL_KEYS[type]).toBeTruthy();
- const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
- expect(
- unit === null || (typeof unit === "string" && unit.length > 0),
- ).toBe(true);
+ it("provides a label key for every type it can render", () => {
+ for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
+ for (const type of group.types) {
+ expect(MEASUREMENT_TYPE_LABEL_KEYS[type], type).toBeTruthy();
+ }
}
});
- it("vital types are a subset of the canonical measurement enum", () => {
+ it("renders only types from the canonical measurement enum", () => {
const enumSet = new Set(measurementTypeEnum.options);
- for (const type of DOCTOR_REPORT_VITAL_TYPES) {
- expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe(
- true,
- );
+ for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
+ for (const type of group.types) {
+ expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe(
+ true,
+ );
+ }
}
});
@@ -645,19 +647,6 @@ describe("extracted doctor-report section boundaries", () => {
expect(text).not.toContain(t("doctorReport.cycleTitle"));
});
- it("clinical records/notes ignores a whitespace-only optional summary", async () => {
- const { t } = getServerTranslator("en");
- const text = await extractText(
- renderDoctorReportPdfBytes(makeData(), {
- t,
- locale: "en",
- now: FIXED_NOW,
- aiSummary: " \n\t ",
- }),
- );
- expect(text).not.toContain(t("doctorReport.aiSummaryTitle"));
- });
-
it("footer is drawn once on every dynamically added page", async () => {
const compliance: DoctorReportData["compliance"] = {};
for (let i = 0; i < 60; i += 1) {
diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts
index 63fb742a3..99a5dc9a2 100644
--- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts
+++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts
@@ -1,10 +1,13 @@
import { describe, it, expect } from "vitest";
-import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
- DOCTOR_REPORT_VITAL_TYPES,
- DOCTOR_REPORT_TYPE_LABEL_KEYS,
+ getUnitForType,
+ measurementTypeEnum,
+} from "@/lib/validations/measurement";
+import {
+ DOCTOR_REPORT_VITAL_GROUPS,
DOCTOR_REPORT_TYPE_UNIT_KEYS,
} from "@/lib/doctor-report-pdf-core";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
// Single source of truth for which measurement types exist.
// V3 audit "enum drift cousins": 7 module-level hardcoded arrays were
@@ -136,170 +139,40 @@ describe("measurementTypeEnum coverage", () => {
// differ by sex/age) which lands with the iOS-app clinical surface.
// Updates to this set MUST be paired with a comment in
// doctor-report-pdf-core.ts so the rationale stays discoverable.
- const PDF_VITAL_EXCLUSIONS = new Set([
- "BLOOD_GLUCOSE",
- "ACTIVITY_STEPS",
- "HEART_RATE_VARIABILITY",
- "RESTING_HEART_RATE",
- "ACTIVE_ENERGY_BURNED",
- "FLIGHTS_CLIMBED",
- "WALKING_RUNNING_DISTANCE",
- "VO2_MAX",
- "BODY_TEMPERATURE",
- "FAT_FREE_MASS",
- "FAT_MASS",
- "MUSCLE_MASS",
- "SKIN_TEMPERATURE",
- "PULSE_WAVE_VELOCITY",
- "VASCULAR_AGE",
- "VISCERAL_FAT",
- // v1.4.25 W8d Apple Health additions held under the same v1.5 gate.
- // Audio exposure and time-in-daylight are lifestyle / environment
- // metrics; reference ranges + clinical layout land alongside the
- // first iOS-app sync, not the v1.4.25 PDF.
- "AUDIO_EXPOSURE_ENV",
- "AUDIO_EXPOSURE_HEADPHONE",
- "TIME_IN_DAYLIGHT",
- // v1.4.30 R-F T1.4 + T1.5 additions held under the same v1.5 gate.
- // Walking-steadiness pairs with a mobility chip; audio-exposure
- // events surface on the Insights audio sub-page. Neither belongs
- // in the clinical PDF until the iOS-app sync lands.
- "WALKING_STEADINESS",
- "AUDIO_EXPOSURE_EVENT",
- // v1.5.5 iOS-coord additions held under the same v1.5 PDF gate.
- // Respiratory rate, BMI, lean body mass, walking HR average, and
- // the two gait-percent metrics ride the same clinical-layout
- // deferral as the other v1.4.23+ additions; the doctor PDF picks
- // them up once the layout and reference ranges land.
- "RESPIRATORY_RATE",
- "BODY_MASS_INDEX",
- "LEAN_BODY_MASS",
- "WALKING_HEART_RATE_AVERAGE",
- "WALKING_ASYMMETRY",
- "WALKING_DOUBLE_SUPPORT",
- // v1.5.5 iOS-coord follow-up — raw-SI gait pair held under the
- // same clinical-layout deferral as the rest of the Mobility set.
- "WALKING_STEP_LENGTH",
- "WALKING_SPEED",
- // v1.10.0 — additive HealthKit signals (WX-A) held under the same
- // v1.5+ clinical-layout PDF gate as the rest of the Apple-Health
- // additions. Cardio recovery, wrist temperature, falls, six-minute
- // walk, the stair gait speeds, and the sleep-breathing index each
- // surface on their Insights sub-page; the doctor PDF picks them up
- // once a clinical layout + reference ranges land.
- "CARDIO_RECOVERY",
- "WRIST_TEMPERATURE",
- "FALL_COUNT",
- "SIX_MINUTE_WALK_DISTANCE",
- "STAIR_ASCENT_SPEED",
- "STAIR_DESCENT_SPEED",
- "BREATHING_DISTURBANCES",
- // v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are
- // discrete on-device notifications, not vital-sign readings. They are
- // surfaced on the Insights awareness timeline with their own regulatory
- // disclaimer, never in the clinical vitals PDF (their value is always 1
- // and they carry the device's verdict, not a measured quantity). See
- // doctor-report-pdf-core.ts for the matching exclusion rationale.
- "IRREGULAR_RHYTHM_NOTIFICATION",
- "HIGH_HEART_RATE_EVENT",
- "LOW_HEART_RATE_EVENT",
- "WALKING_STEADINESS_EVENT",
- "BREATHING_DISTURBANCE_EVENT",
- // v1.10.0 — computed scores (WX-C). Server-derived wellness scores
- // (Recovery / Stress / Strain) are 0–100 composites recomputed nightly,
- // not measured clinical vitals. They surface on their own Insights
- // cluster with a "descriptive, not clinical" disclaimer and never belong
- // in the clinical vitals PDF. See doctor-report-pdf-core.ts.
- "RECOVERY_SCORE",
- "STRESS_SCORE",
- "STRAIN_SCORE",
- // v1.11.0 — WHOOP-native score classes. Device-derived strain / recovery
- // / sleep-quality composites and a kJ energy total, not measured clinical
- // vitals. They surface on their own Insights cluster with a "descriptive,
- // not clinical" disclaimer and never belong in the clinical vitals PDF.
- // See doctor-report-pdf-core.ts for the matching exclusion rationale.
- "HRV_RMSSD",
- "DAY_STRAIN",
- "WORKOUT_STRAIN",
- "SLEEP_PERFORMANCE",
- "SLEEP_EFFICIENCY",
- "SLEEP_CONSISTENCY",
- "SLEEP_NEED",
- "ENERGY_EXPENDITURE_KJ",
- // v1.12.8 — WHOOP cycle + sleep coverage completion. The daily-aggregate
- // heart-rate pair and the per-night disturbance count are device-derived
- // WHOOP signals, not measured clinical vitals; they surface on the
- // measurement list + Insights chart surfaces with a "descriptive, not
- // clinical" framing and never belong in the clinical vitals PDF. See
- // doctor-report-pdf-core.ts for the matching exclusion rationale.
- "AVERAGE_HEART_RATE",
- "MAX_HEART_RATE",
- "SLEEP_DISTURBANCE_COUNT",
- // v1.17.1 — Polar Nightly Recharge + Training Load Pro components. ANS
- // charge and Cardio Load are device-derived recovery / strain composites,
- // not measured clinical vitals; they surface on their own Insights cluster
- // with a "descriptive, not clinical" framing and never belong in the
- // clinical vitals PDF. See doctor-report-pdf-core.ts.
- "ANS_CHARGE",
- "CARDIO_LOAD",
- // v1.17.1 — Oura coverage completion. The Sleep Score is a nightly
- // derived composite and the body-temperature deviation is a signed
- // baseline offset (illness / cycle / stress signal), not a measured
- // clinical vital — both surface on the Insights / measurement-list
- // surfaces and never belong in the clinical vitals PDF. See
- // doctor-report-pdf-core.ts for the matching exclusion rationale.
- "SLEEP_SCORE",
- "BODY_TEMPERATURE_DEVIATION",
- // v1.19.0 — Oura resilience. The resilience level is an ordinal-encoded
- // categorical band (limited=1 … exceptional=5), a derived recovery
- // composite, not a measured clinical vital — it surfaces on the
- // Insights / measurement-list surfaces and never belongs in the clinical
- // vitals PDF. See doctor-report-pdf-core.ts for the matching rationale.
- "RESILIENCE",
- // v1.25 — clinical-signals wave. The mental-health screener totals are
- // opt-in, derived, and deliberately excluded from any export/share/AI
- // surface by default (item answers are encrypted). The physical clinical
- // signals (grip strength, pain NRS, waist circumference + WHtR) surface on
- // their own measurement/insights surfaces; they join the doctor PDF once a
- // clinical layout + reference ranges land alongside the other v1.5+
- // additions. See doctor-report-pdf-core.ts for the matching rationale.
- "PHQ9_SCORE",
- "GAD7_SCORE",
- "GRIP_STRENGTH",
- "PAIN_NRS",
- "WAIST_CIRCUMFERENCE",
- "WAIST_TO_HEIGHT",
- // v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
- // precedent: opt-in questionnaire derivations, excluded from the vitals
- // table; they ride the module-gated mental-health export section
- // instead. See doctor-report-pdf-core.ts for the matching rationale.
- "WHO5_SCORE",
- "SCI_SCORE",
- ]);
- it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
- const expected = measurementTypeEnum.options.filter(
- (t) => !PDF_VITAL_EXCLUSIONS.has(t),
+ it("doctor-report PDF renders every measurement type, grouped", () => {
+ // The table used to be a fixed nine-type whitelist with a documented
+ // exclusion list beside it, which meant a user could select resting heart
+ // rate, get it in the FHIR bundle and the share view, and not find it in
+ // the PDF. It is selection-driven now, so the assertion inverts: every
+ // type in the enum appears in exactly one group, and the exclusion list is
+ // gone with the whitelist that needed it.
+ const rendered = DOCTOR_REPORT_VITAL_GROUPS.flatMap((g) => g.types);
+ expect([...rendered].sort()).toEqual(
+ [...measurementTypeEnum.options].sort(),
);
- expect([...DOCTOR_REPORT_VITAL_TYPES].sort()).toEqual([...expected].sort());
+ expect(new Set(rendered).size).toBe(rendered.length);
});
it("doctor-report PDF has a label key for every renderable type", () => {
- for (const type of DOCTOR_REPORT_VITAL_TYPES) {
- expect(
- DOCTOR_REPORT_TYPE_LABEL_KEYS[type],
- `missing label key for ${type}`,
- ).toBeTruthy();
+ for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
+ for (const type of group.types) {
+ expect(
+ MEASUREMENT_TYPE_LABEL_KEYS[type],
+ `missing label key for ${type}`,
+ ).toBeTruthy();
+ }
}
});
- it("doctor-report PDF has a unit key for every renderable type", () => {
- for (const type of DOCTOR_REPORT_VITAL_TYPES) {
- const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
- expect(
- unit === null || (typeof unit === "string" && unit.length > 0),
- `missing unit for ${type}`,
- ).toBe(true);
+ it("doctor-report PDF resolves a unit or honestly none for every type", () => {
+ // A type with no recorded unit prints none. An absent unit is absent, not
+ // the word "unknown" beside a clinical number.
+ for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
+ for (const type of group.types) {
+ const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type] ?? getUnitForType(type);
+ expect(typeof unit === "string" || unit === null).toBe(true);
+ }
}
});
});
diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts
index 2317f375f..566c86175 100644
--- a/src/lib/clinician-share/__tests__/share-view-data.test.ts
+++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts
@@ -16,9 +16,9 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
}));
-// The real prefs parser is used so the "no section enabled ⇒ documents-only"
-// signal is exercised end to end (a stubbed parser would let the test lie
-// about which sectionsJson resolves to an empty report scope).
+// The real selection resolver is used so the "empty scope ⇒ documents-only"
+// signal is exercised end to end (a stubbed one would let the test lie about
+// which sectionsJson resolves to an empty report scope).
vi.mock("@/lib/db", () => ({
prisma: {
clinicianShareLinkDocument: { findMany: vi.fn() },
@@ -28,7 +28,10 @@ vi.mock("@/lib/db", () => ({
import { loadShareViewData } from "../share-view-data";
import { isModuleEnabled } from "@/lib/modules/gate";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
-import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
+import {
+ selectionToBlob,
+ selectionFromLeaves,
+} from "@/lib/report-selection/selection";
import { prisma } from "@/lib/db";
import type { ShareContext } from "../resolve-share-token";
@@ -44,7 +47,7 @@ function ctx(overrides: Partial = {}): ShareContext {
label: "Clinic",
rangeStart: new Date("2026-01-01T00:00:00Z"),
rangeEnd: new Date("2026-02-01T00:00:00Z"),
- sectionsJson: { mood: false },
+ sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT", "PULSE"])),
expiresAt: new Date(Date.now() + 86_400_000),
...overrides,
} as ShareContext;
@@ -67,14 +70,16 @@ describe("loadShareViewData — KVNR default OFF", () => {
it("never requests an identifier / KVNR opt-in from the aggregator", async () => {
await loadShareViewData(ctx());
- const opts = collect.mock.calls[0]![2] as Record;
- // The only option ever passed is the frozen section toggles — no
- // includeIdentifiers, no kvnr, no decrypt flag. Default-OFF by absence.
- expect(opts).toBeDefined();
- expect(opts).not.toHaveProperty("includeIdentifiers");
- expect(opts).not.toHaveProperty("kvnr");
- expect(opts).not.toHaveProperty("insuranceNumber");
- expect(Object.keys(opts)).toEqual(["sections"]);
+ // The third argument is the link's frozen selection and nothing else — no
+ // includeIdentifiers, no kvnr, no decrypt flag, and no fourth argument at
+ // all. The insurance leaf is refused at create, so it cannot be in there.
+ const selection = collect.mock.calls[0]![2] as {
+ has: (leaf: string) => boolean;
+ leaves: string[];
+ };
+ expect(selection.leaves).toEqual(["PULSE", "WEIGHT"]);
+ expect(selection.has("INSURANCE")).toBe(false);
+ expect(collect.mock.calls[0]![3]).toBeUndefined();
});
it("returns a report payload carrying no insurance number", async () => {
@@ -187,7 +192,7 @@ describe("loadShareViewData — documents-only share exposes no health data", ()
]);
const { report, documentOnly, documents } = await loadShareViewData(
- ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS }),
+ ctx({ sectionsJson: selectionToBlob(selectionFromLeaves([])) }),
);
// The one guarantee: the aggregator is NEVER invoked — no health data is
@@ -210,17 +215,31 @@ describe("loadShareViewData — documents-only share exposes no health data", ()
]);
});
- it("still aggregates for a record share (defaults resolve to an enabled scope)", async () => {
- // `{}` / null sections resolve to the documented defaults (a full record
- // share), so the aggregator DOES run — the empty-scope short-circuit must
- // not swallow a normal record share.
+ it("still aggregates for a record share that names leaves", async () => {
+ // A link whose frozen scope names leaves DOES aggregate — the empty-scope
+ // short-circuit must not swallow a normal record share.
const { report, documentOnly } = await loadShareViewData(
- ctx({ sectionsJson: {}, documentOnly: false }),
+ ctx({ documentOnly: false }),
);
expect(collect).toHaveBeenCalledTimes(1);
expect(documentOnly).toBe(false);
expect(report).not.toBeNull();
});
+
+ it("serves nothing for a legacy blob this build cannot read", async () => {
+ // Every such link was revoked by migration 0273; this is the second lock
+ // behind that. A shape the server cannot read is not consent to anything,
+ // so it resolves to the empty selection rather than to a default scope.
+ const { report, documentOnly } = await loadShareViewData(
+ ctx({
+ sectionsJson: { bp: true, weight: true, pulse: true },
+ documentOnly: false,
+ }),
+ );
+ expect(collect).not.toHaveBeenCalled();
+ expect(report).toBeNull();
+ expect(documentOnly).toBe(true);
+ });
});
/**
@@ -244,7 +263,7 @@ describe("loadShareViewData — documentOnly column is authoritative", () => {
// the exact future-leak the column closes: a new section defaulting on can
// no longer widen an old documents-only link.
const { report, documentOnly } = await loadShareViewData(
- ctx({ sectionsJson: { bp: true }, documentOnly: true }),
+ ctx({ documentOnly: true }),
);
expect(collect).not.toHaveBeenCalled();
expect(report).toBeNull();
@@ -255,7 +274,10 @@ describe("loadShareViewData — documentOnly column is authoritative", () => {
// A pre-column documents-only link reads `documentOnly:false` from the row
// but still has every section off — the derived fallback keeps it closed.
const { report, documentOnly } = await loadShareViewData(
- ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS, documentOnly: false }),
+ ctx({
+ sectionsJson: selectionToBlob(selectionFromLeaves([])),
+ documentOnly: false,
+ }),
);
expect(collect).not.toHaveBeenCalled();
expect(report).toBeNull();
@@ -264,92 +286,47 @@ describe("loadShareViewData — documentOnly column is authoritative", () => {
});
/**
- * v1.28.17 — the create schema accepts the GROUPED export sections shape
- * (`{ vitals: { bp, weight, … }, activity: { sleep }, medications: { … } }`)
- * and persists it raw. The clinician-view loader MUST fold that grouped shape
- * down to the flat toggles the aggregator consumes — reading it through the
- * flat doctor-report parser silently drops every grouped toggle and re-defaults
- * the section back ON, re-widening a scope the owner explicitly narrowed. This
- * pins that a section switched OFF in the grouped shape reaches the aggregator
- * as OFF, not as a defaults-on leak.
+ * The frozen scope reaches the aggregator exactly as it was stored.
+ *
+ * The grouped/flat fold this block used to pin is gone with the shapes it
+ * folded between: the link stores the leaf list the owner chose and the
+ * aggregator reads that same list, so there is no second representation left
+ * to disagree with the first.
*/
-describe("loadShareViewData — grouped sections are folded, not silently defaulted", () => {
+describe("loadShareViewData — the frozen scope reaches the aggregator intact", () => {
beforeEach(() => {
vi.clearAllMocks();
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
findDocs.mockResolvedValue([]);
});
- it("honours grouped OFF toggles instead of falling back to defaults-ON", async () => {
- // Owner froze a labs-focused share: vitals OFF, but the grouped keys live
- // under `vitals` where the flat parser cannot see them. Pre-fix, bp/weight/
- // pulse resolved to the defaults (ON) and leaked to the clinician.
+ it("passes exactly the leaves the link froze", async () => {
await loadShareViewData(
ctx({
- sectionsJson: {
- vitals: { bp: false, weight: false, pulse: false },
- activity: { sleep: false },
- labs: true,
- },
+ sectionsJson: selectionToBlob(
+ selectionFromLeaves(["LAB_RESULTS", "BLOOD_PRESSURE_SYS"]),
+ ),
documentOnly: false,
}),
);
-
- expect(collect).toHaveBeenCalledTimes(1);
- const opts = collect.mock.calls[0]![2] as {
- sections: Record;
+ const selection = collect.mock.calls[0]![2] as {
+ has: (leaf: string) => boolean;
+ leaves: string[];
};
- // The grouped OFF toggles must survive the fold.
- expect(opts.sections.bp).toBe(false);
- expect(opts.sections.weight).toBe(false);
- expect(opts.sections.pulse).toBe(false);
- expect(opts.sections.sleep).toBe(false);
- // A toggle the owner left ON stays ON.
- expect(opts.sections.labs).toBe(true);
+ expect(selection.leaves).toEqual(["BLOOD_PRESSURE_SYS", "LAB_RESULTS"]);
+ expect(selection.has("WEIGHT")).toBe(false);
+ expect(selection.has("MOOD")).toBe(false);
});
- it("folds a grouped glucose=OFF toggle down to sections.glucose=false", async () => {
- // A diabetic owner withholds glucose from THIS share without disabling the
- // glucose module app-wide. The grouped `glucose` toggle must survive the
- // fold and reach the aggregator so no glucose panel is built.
+ it("drops a leaf id this build no longer knows", async () => {
await loadShareViewData(
ctx({
- sectionsJson: {
- vitals: { bp: true },
- glucose: false,
- },
+ sectionsJson: { v: 2, leaves: ["WEIGHT", "SOMETHING_RETIRED"] },
documentOnly: false,
}),
);
- const opts = collect.mock.calls[0]![2] as {
- sections: Record;
- };
- expect(opts.sections.glucose).toBe(false);
- // A share that leaves glucose unspecified keeps the default-ON behaviour.
- vi.clearAllMocks();
- collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
- findDocs.mockResolvedValue([]);
- await loadShareViewData(
- ctx({ sectionsJson: { vitals: { bp: true } }, documentOnly: false }),
- );
- const opts2 = collect.mock.calls[0]![2] as {
- sections: Record;
- };
- expect(opts2.sections.glucose).toBe(true);
- });
-
- it("still resolves a flat legacy blob through the flat parser unchanged", async () => {
- // A flat shape has no grouped-only key, so it keeps the exact legacy path.
- await loadShareViewData(
- ctx({ sectionsJson: { bp: false, mood: true }, documentOnly: false }),
- );
- const opts = collect.mock.calls[0]![2] as {
- sections: Record;
- };
- expect(opts.sections.bp).toBe(false);
- expect(opts.sections.mood).toBe(true);
- // Unspecified flat keys fall back to the documented defaults.
- expect(opts.sections.weight).toBe(true);
+ const selection = collect.mock.calls[0]![2] as { leaves: string[] };
+ expect(selection.leaves).toEqual(["WEIGHT"]);
});
});
@@ -380,9 +357,7 @@ describe("clinician share — owner doctorReport module gate", () => {
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
findDocs.mockResolvedValue([]);
- const res = await loadShareViewData(
- ctx({ sectionsJson: { bp: true }, documentOnly: false }),
- );
+ const res = await loadShareViewData(ctx({ documentOnly: false }));
expect(res.documentOnly).toBe(false);
expect(res.report).not.toBeNull();
@@ -393,9 +368,7 @@ describe("clinician share — owner doctorReport module gate", () => {
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
findDocs.mockResolvedValue([]);
- const res = await loadShareViewData(
- ctx({ sectionsJson: { bp: true }, documentOnly: false }),
- );
+ const res = await loadShareViewData(ctx({ documentOnly: false }));
expect(res.documentOnly).toBe(true);
expect(res.report).toBeNull();
@@ -408,9 +381,7 @@ describe("clinician share — owner doctorReport module gate", () => {
// The link is public; there is no viewer session. The owner id must come
// from the frozen share context.
findDocs.mockResolvedValue([]);
- await loadShareViewData(
- ctx({ sectionsJson: { bp: true }, documentOnly: false }),
- );
+ await loadShareViewData(ctx({ documentOnly: false }));
expect(isModuleEnabled).toHaveBeenCalledWith("owner-1", "doctorReport");
});
@@ -420,9 +391,7 @@ describe("clinician share — owner doctorReport module gate", () => {
);
findDocs.mockResolvedValue([]);
- const res = await loadShareViewData(
- ctx({ sectionsJson: { bp: true }, documentOnly: false }),
- );
+ const res = await loadShareViewData(ctx({ documentOnly: false }));
expect(res.documentOnly).toBe(true);
expect(collect).not.toHaveBeenCalled();
diff --git a/src/lib/clinician-share/resolve-share-token.ts b/src/lib/clinician-share/resolve-share-token.ts
index d539ffd7e..f0793bdac 100644
--- a/src/lib/clinician-share/resolve-share-token.ts
+++ b/src/lib/clinician-share/resolve-share-token.ts
@@ -8,12 +8,13 @@
* (plus the frozen scope). It is NOT an `AuthContext`/session and can never
* stand in for one.
* - It never calls `getSession` / `authenticateBearer` and never sets a
- * cookie. A share token can authenticate exactly one surface — the public
- * clinician view (`/c/[token]`) and, when enabled, its scoped FHIR face —
- * and nothing else.
- * - The token arrives ONLY via the `X-HealthLog-Share` request header. It is
- * never read from `Authorization: Bearer`; presenting it on a normal authed
- * route does nothing (that route's `requireAuth` ignores this header).
+ * cookie. A share token authenticates exactly one family of surfaces — the
+ * public clinician view at `/c/[token]`, its document serve route, and its
+ * two report downloads — and nothing else.
+ * - The token arrives ONLY in the URL PATH of those surfaces. Nothing reads
+ * it from a header, and presenting it as `Authorization: Bearer` on a
+ * normal authed route does nothing: `requireAuth` refuses an `hls_` value,
+ * which `share-token-reject-on-authed-route.test.ts` pins.
*
* Resolution is blunt on failure: an unknown / revoked / expired / malformed
* token resolves to `null`, and the caller answers a flat 404 so a probe cannot
diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts
new file mode 100644
index 000000000..f5b37ae23
--- /dev/null
+++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts
@@ -0,0 +1,403 @@
+/**
+ * The anti-drift sweep: for every leaf that has data, aggregating with it and
+ * without it must produce different payloads.
+ *
+ * This is the test that would have caught all eight dead switches on the day
+ * they were added, and it is parametrised over the catalogue, so a leaf added
+ * later is covered the day it is added rather than the day someone remembers.
+ * It covers the PDF, the FHIR bundle, the share view and the MCP surface
+ * transitively, because all four read this function's output.
+ *
+ * Two further properties sit beside it:
+ *
+ * - fail-closed: a measurement type the catalogue does not know is excluded,
+ * not passed through. The old filter did the opposite, which made "we
+ * forgot to map it" indistinguishable from "the user asked for it".
+ * - zero read: a leaf that was not chosen is never queried. The payload has
+ * to reflect "this was never fetched", not "this was fetched and dropped".
+ *
+ * Mutation checks:
+ * - `resolveReportGate`: make `admits` return `true` unconditionally → the
+ * sweep goes red listing every leaf, and the empty-selection assertions go
+ * red too.
+ * - `filterMeasurementKeys`: pass an unmapped type through instead of
+ * skipping it → "excludes a measurement type the catalogue does not know"
+ * goes red.
+ * - `collect.ts`: drop the `gate.admits("MOOD")` condition on the mood query
+ * → "never queries moodEntry for MOOD when it was not chosen" goes red.
+ * - `collect.ts`: return `bmi` unconditionally → the sweep goes red naming
+ * BODY_MASS_INDEX.
+ * - `collect.ts`: drop the `identityOn` guard on `patient` → the sweep goes
+ * red naming PATIENT_IDENTITY.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const { calls, rows, prismaMock } = vi.hoisted(() => {
+ const calls: string[] = [];
+ const rows: Record = {};
+ const model = (name: string) => ({
+ findMany: (args?: { where?: { type?: { notIn?: string[] } } }) => {
+ calls.push(name);
+ if (name === "measurement") {
+ const blocked = new Set(args?.where?.type?.notIn ?? []);
+ return Promise.resolve(
+ (rows.measurement ?? []).filter(
+ (r) => !blocked.has((r as { type: string }).type),
+ ),
+ );
+ }
+ return Promise.resolve(rows[name] ?? []);
+ },
+ findUnique: () => {
+ calls.push(name);
+ return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
+ },
+ });
+ return {
+ calls,
+ rows,
+ prismaMock: new Proxy(
+ {},
+ {
+ get: (_t, prop: string) =>
+ prop === "$queryRaw" ? () => Promise.resolve([]) : model(prop),
+ },
+ ),
+ };
+});
+
+vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
+vi.mock("@/lib/cycle/export-data", () => ({
+ buildCycleExportSummary: vi.fn(async () => ({
+ lastPeriodStart: "2026-02-01",
+ recentCycles: [
+ { startDate: "2026-02-01", lengthDays: 28, periodLengthDays: 5 },
+ ],
+ observedCycleCount: 1,
+ averageCycleLengthDays: 28,
+ cycleLengthVariabilityDays: null,
+ averagePeriodLengthDays: 5,
+ currentPhase: "FOLLICULAR",
+ })),
+}));
+
+import { collectDoctorReportData } from "../collect";
+import { filterMeasurementKeys, resolveReportGate } from "../selection-gate";
+import { MODULE_KEYS } from "@/lib/modules/gate";
+import {
+ ALL_LEAF_IDS,
+ MEASUREMENT_LEAF_IDS,
+ type ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
+import type { DoctorReportData } from "@/lib/doctor-report-types";
+
+const RANGE = {
+ start: new Date("2026-01-01T00:00:00.000Z"),
+ end: new Date("2026-03-01T00:00:00.000Z"),
+ days: 60,
+};
+
+function moduleMap() {
+ const out: Record = {};
+ for (const key of MODULE_KEYS) out[key] = true;
+ return out as never;
+}
+
+/**
+ * One row for every measurement type plus one row behind every structured
+ * leaf, so no leaf's difference can be hidden by an empty account. The
+ * positive control below asserts the all-on payload is genuinely populated,
+ * which is what stops the sweep passing vacuously.
+ */
+function seed() {
+ for (const key of Object.keys(rows)) delete rows[key];
+ rows.measurement = MEASUREMENT_LEAF_IDS.map((type, i) => ({
+ type,
+ value: 10 + i,
+ measuredAt: new Date("2026-02-01T08:00:00.000Z"),
+ source: "MANUAL",
+ deviceType: null,
+ sleepStage: null,
+ glucoseContext: type === "BLOOD_GLUCOSE" ? "FASTING" : null,
+ }));
+ rows.userUnique = [
+ {
+ username: "owner",
+ dateOfBirth: new Date("1980-05-05T00:00:00.000Z"),
+ gender: "OTHER",
+ heightCm: 180,
+ glucoseUnit: "MG_DL",
+ thresholdsJson: null,
+ timezone: "Europe/Berlin",
+ sourcePriorityJson: null,
+ fullName: "Owner Name",
+ insurerName: "Sample Insurer",
+ insurerIkNumber: "123456789",
+ },
+ ];
+ rows.medication = [
+ {
+ id: "m1",
+ name: "Sample drug",
+ dose: "10 mg",
+ atcCode: null,
+ rxNormCode: null,
+ asNeeded: false,
+ active: true,
+ treatmentClass: "GLP1",
+ startsOn: null,
+ endsOn: null,
+ oneShot: false,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ schedules: [
+ {
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ label: null,
+ daysOfWeek: null,
+ },
+ ],
+ scheduleRevisions: [],
+ pauseEras: [],
+ doseChanges: [
+ {
+ effectiveFrom: new Date("2026-01-05T00:00:00.000Z"),
+ doseValue: 1,
+ doseUnit: "mg",
+ note: null,
+ noteEncrypted: null,
+ },
+ ],
+ intakeEvents: [
+ { takenAt: new Date("2026-02-10T08:00:00.000Z"), injectionSite: "ARM" },
+ ],
+ },
+ ];
+ rows.medicationIntakeEvent = [
+ {
+ medicationId: "m1",
+ scheduledFor: new Date("2026-02-10T08:00:00.000Z"),
+ takenAt: new Date("2026-02-10T08:05:00.000Z"),
+ skipped: false,
+ autoMissed: false,
+ attributionSource: null,
+ injectionSite: "ARM",
+ medication: {
+ id: "m1",
+ name: "Sample drug",
+ dose: "10 mg",
+ atcCode: null,
+ rxNormCode: null,
+ deliveryForm: null,
+ asNeeded: false,
+ },
+ },
+ ];
+ rows.moodEntry = [{ score: 4, tags: '["nausea"]' }];
+ rows.labResult = [
+ {
+ panel: "Lipids",
+ analyte: "LDL",
+ value: 3.1,
+ valueText: null,
+ unit: "mmol/L",
+ referenceLow: null,
+ referenceHigh: null,
+ takenAt: new Date("2026-02-01T00:00:00.000Z"),
+ },
+ ];
+ rows.illnessEpisode = [
+ {
+ label: "Sample condition",
+ type: "ACUTE",
+ lifecycle: "RESOLVED",
+ onsetAt: new Date("2026-01-10T00:00:00.000Z"),
+ resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
+ },
+ ];
+ rows.allergy = [
+ {
+ substance: "Penicillin",
+ category: "MEDICATION",
+ type: "ALLERGY",
+ severity: "SEVERE",
+ status: "ACTIVE",
+ reactionEncrypted: null,
+ },
+ ];
+ rows.familyHistoryEntry = [
+ { relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
+ ];
+}
+
+function same(a: DoctorReportData, b: DoctorReportData): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+beforeEach(() => {
+ calls.length = 0;
+ seed();
+});
+
+describe("per-leaf gating sweep", () => {
+ it("changes the payload for every leaf", async () => {
+ const all = selectionFromLeaves(ALL_LEAF_IDS);
+ const allPayload = await collectDoctorReportData("u1", RANGE, all, {
+ moduleMap: moduleMap(),
+ });
+
+ // Positive control: the fixture really does populate the payload, so a
+ // "no difference" result below means the gate is inert, not that the
+ // account is empty.
+ expect(Object.keys(allPayload.stats).length).toBeGreaterThan(70);
+ expect(allPayload.labResults).not.toBeNull();
+ expect(allPayload.allergies).not.toBeNull();
+ expect(allPayload.familyHistory).not.toBeNull();
+ expect(allPayload.illnessEpisodes).not.toBeNull();
+ expect(allPayload.mood).not.toBeNull();
+ expect(allPayload.cycle).not.toBeNull();
+ expect(allPayload.glp1).not.toBeNull();
+ expect(allPayload.medications.length).toBeGreaterThan(0);
+ expect(allPayload.medicationAdministrations?.length ?? 0).toBeGreaterThan(
+ 0,
+ );
+ expect(Object.keys(allPayload.compliance).length).toBeGreaterThan(0);
+ expect(allPayload.patient.username).toBe("owner");
+ expect(allPayload.patient.insurerName).toBe("Sample Insurer");
+ expect(allPayload.bmi).not.toBeNull();
+ expect(Object.keys(allPayload.glucoseStats).length).toBeGreaterThan(0);
+
+ const inert: ReportLeafId[] = [];
+ for (const leaf of ALL_LEAF_IDS) {
+ const without = selectionFromLeaves(
+ ALL_LEAF_IDS.filter((l) => l !== leaf),
+ );
+ const payload = await collectDoctorReportData("u1", RANGE, without, {
+ moduleMap: moduleMap(),
+ });
+ if (same(allPayload, payload)) inert.push(leaf);
+ }
+
+ expect(
+ inert,
+ "withholding these leaves changed nothing that leaves the aggregator, " +
+ "so a control for them would lie",
+ ).toEqual([]);
+ }, 30_000);
+});
+
+describe("fail-closed exclusion", () => {
+ it("excludes a measurement type the catalogue does not know", () => {
+ const gate = resolveReportGate(
+ selectionFromLeaves(["WEIGHT"]),
+ moduleMap(),
+ );
+ expect(
+ filterMeasurementKeys({ WEIGHT: 1, SOMETHING_NEW: 2 }, gate),
+ ).toEqual({ WEIGHT: 1 });
+ });
+
+ it("admits a mapped, selected type", () => {
+ const gate = resolveReportGate(
+ selectionFromLeaves(MEASUREMENT_LEAF_IDS),
+ moduleMap(),
+ );
+ expect(filterMeasurementKeys({ WEIGHT: 1, PULSE: 2 }, gate)).toEqual({
+ WEIGHT: 1,
+ PULSE: 2,
+ });
+ });
+
+ it("refuses a selected type whose module is off", () => {
+ const map = { ...(moduleMap() as unknown as Record) };
+ map.sleep = false;
+ const gate = resolveReportGate(
+ selectionFromLeaves(["SLEEP_DURATION", "WEIGHT"]),
+ map as never,
+ );
+ expect(
+ filterMeasurementKeys({ SLEEP_DURATION: 1, WEIGHT: 2 }, gate),
+ ).toEqual({ WEIGHT: 2 });
+ });
+});
+
+describe("zero read for unchosen leaves", () => {
+ const gated: Array<[ReportLeafId, string]> = [
+ ["MOOD", "moodEntry"],
+ ["FAMILY_HISTORY", "familyHistoryEntry"],
+ ["ALLERGIES", "allergy"],
+ ["LAB_RESULTS", "labResult"],
+ ["ILLNESS_EPISODES", "illnessEpisode"],
+ ];
+
+ it.each(gated)(
+ "never queries %s's table when it was not chosen",
+ async (leaf, model) => {
+ calls.length = 0;
+ await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(ALL_LEAF_IDS.filter((l) => l !== leaf)),
+ { moduleMap: moduleMap() },
+ );
+ expect(calls).not.toContain(model);
+ },
+ );
+
+ it.each(gated)(
+ "does query %s's table when it was chosen",
+ async (_leaf, model) => {
+ calls.length = 0;
+ await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(ALL_LEAF_IDS),
+ { moduleMap: moduleMap() },
+ );
+ expect(calls).toContain(model);
+ },
+ );
+
+ it("reads no medication row when no medication leaf was chosen", async () => {
+ calls.length = 0;
+ await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["WEIGHT"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(calls).not.toContain("medication");
+ expect(calls).not.toContain("medicationIntakeEvent");
+ });
+});
+
+describe("the empty selection serves nothing", () => {
+ it("returns an empty payload for an empty selection", async () => {
+ const payload = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves([]),
+ { moduleMap: moduleMap() },
+ );
+ expect(payload.patient.username).toBeNull();
+ expect(payload.patient.insurerName).toBeNull();
+ expect(payload.labResults).toBeNull();
+ expect(payload.allergies).toBeNull();
+ expect(payload.familyHistory).toBeNull();
+ expect(payload.illnessEpisodes).toBeNull();
+ expect(payload.cycle).toBeNull();
+ expect(payload.mood).toBeNull();
+ expect(payload.glp1).toBeNull();
+ expect(payload.bmi).toBeNull();
+ expect(payload.wellnessScores).toBeNull();
+ expect(payload.medications).toEqual([]);
+ expect(payload.medicationAdministrations).toEqual([]);
+ expect(payload.compliance).toEqual({});
+ expect(payload.measurements).toEqual({});
+ expect(payload.stats).toEqual({});
+ expect(payload.glucoseStats).toEqual({});
+ expect(payload.glucoseRanges).toEqual({});
+ expect(payload.glucoseClinical.readingCount).toBe(0);
+ });
+});
diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
index 4dc6553aa..0feaa790b 100644
--- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
+++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
@@ -102,6 +102,7 @@ function buildUser(disableCoach: boolean): AuthUser {
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
+ reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
};
diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts
index 58c281091..8947e9fa2 100644
--- a/src/lib/fhir/__tests__/build-bundle.test.ts
+++ b/src/lib/fhir/__tests__/build-bundle.test.ts
@@ -36,6 +36,27 @@ function observationsOf(bundle: ReturnType) {
.filter((r): r is FhirObservation => r.resourceType === "Observation");
}
+/**
+ * Follow an intra-document reference the way a receiver does: match it against
+ * `Bundle.entry.fullUrl`. Every internal reference is a `urn:uuid`, so
+ * asserting the literal string would only pin the UUID generator.
+ */
+function resolveRef(
+ bundle: ReturnType,
+ reference: string | undefined,
+) {
+ return bundle.entry.find((e) => e.fullUrl === reference)?.resource;
+}
+
+/** `"Patient/patient-1"` for whatever entry the reference resolves to. */
+function refTarget(
+ bundle: ReturnType,
+ reference: string | undefined,
+): string | undefined {
+ const resource = resolveRef(bundle, reference);
+ return resource ? `${resource.resourceType}/${resource.id}` : undefined;
+}
+
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
function makeData(overrides?: Partial): DoctorReportData {
@@ -167,7 +188,9 @@ describe("buildFhirDocumentBundle", () => {
const coverage = coverageOf(bundle);
expect(coverage).toBeDefined();
expect(coverage?.status).toBe("active");
- expect(coverage?.beneficiary.reference).toBe("Patient/patient-1");
+ expect(refTarget(bundle, coverage?.beneficiary.reference)).toBe(
+ "Patient/patient-1",
+ );
expect(coverage?.subscriberId).toBe("A123456780");
// Payor references the contained Organization via a local #-ref.
const payorRef = coverage?.payor?.[0].reference;
@@ -268,7 +291,7 @@ describe("buildFhirDocumentBundle", () => {
expect(coverageOf(bundle)).toBeUndefined();
});
- it("emits a KVNR-only Coverage (no insurer) to align with the iOS exporter", () => {
+ it("emits no Coverage for a bare KVNR — a Coverage needs a payor", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
@@ -283,15 +306,10 @@ describe("buildFhirDocumentBundle", () => {
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
- const coverage = coverageOf(bundle);
- expect(coverage).toBeDefined();
- expect(coverage?.status).toBe("active");
- expect(coverage?.beneficiary.reference).toBe("Patient/patient-1");
- expect(coverage?.subscriberId).toBe("A123456780");
- // No insurer known → no contained payor Organization, no payor[].
- expect(coverage?.contained).toBeUndefined();
- expect(coverage?.payor).toBeUndefined();
- // KVNR still rides on Patient.identifier too.
+ // R4 marks `Coverage.payor` 1..*: with no insurer name and no IKNR there
+ // is nobody to name, and a Coverage that names no payor is not one.
+ expect(coverageOf(bundle)).toBeUndefined();
+ // Nothing is lost — the member id rides Patient.identifier.
const patient = bundle.entry.find(
(e) => e.resource.resourceType === "Patient",
)?.resource as FhirPatient;
@@ -656,7 +674,9 @@ describe("buildFhirDocumentBundle", () => {
composition.resourceType === "Composition"
? composition.section?.find((s) => s.title === "Medications")
: undefined;
- const refs = medSection?.entry?.map((e) => e.reference) ?? [];
+ const refs = (medSection?.entry ?? []).map((e) =>
+ refTarget(bundle, e.reference),
+ );
expect(refs).toContain("MedicationAdministration/medadmin-1");
});
@@ -1062,7 +1082,11 @@ describe("buildFhirDocumentBundle", () => {
(c) => c.code === "HKQuantityTypeIdentifierFlightsClimbed",
),
);
- expect(audio?.valueQuantity?.code).toBe("dB[A]");
+ // `dB[A]` is a readable label, not a UCUM expression — it stays on the
+ // display `unit` and is never stamped as a coded UCUM symbol.
+ expect(audio?.valueQuantity?.unit).toBe("dB[A]");
+ expect(audio?.valueQuantity?.code).toBeUndefined();
+ expect(audio?.valueQuantity?.system).toBeUndefined();
expect(flights?.valueQuantity?.code).toBe("{flights}");
});
@@ -1160,9 +1184,19 @@ describe("buildFhirDocumentBundle", () => {
expect(report.code.coding?.[0].code).toBe("85353-1");
expect(report.effectivePeriod?.start).toBe("2026-02-02T00:00:00.000Z");
expect(report.effectivePeriod?.end).toBe("2026-05-03T12:00:00.000Z");
- const obsCount = observationsOf(bundle).length;
- expect(report.result).toHaveLength(obsCount);
- expect(obsCount).toBeGreaterThan(0);
+ // A vital-signs panel reports vital signs, not labs / adherence / surveys.
+ const vitals = observationsOf(bundle).filter((o) =>
+ (o.category ?? []).some((c) =>
+ (c.coding ?? []).some((coding) => coding.code === "vital-signs"),
+ ),
+ );
+ expect(vitals.length).toBeGreaterThan(0);
+ expect(vitals.length).toBeLessThan(observationsOf(bundle).length);
+ expect(report.result).toHaveLength(vitals.length);
+ const referenced = (report.result ?? []).map((r) =>
+ refTarget(bundle, r.reference),
+ );
+ expect(referenced).toEqual(vitals.map((o) => `Observation/${o.id}`));
});
it("uses 'Vital signs' + 'Medications' Composition sections", () => {
@@ -1250,7 +1284,9 @@ describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => {
expect(enc.status).toBe("finished");
expect(enc.period?.start).toBe("2026-04-01T00:00:00.000Z");
expect(enc.period?.end).toBe("2026-04-10T00:00:00.000Z");
- expect(enc.reasonReference?.[0].reference).toBe(`Condition/${c.id}`);
+ expect(refTarget(bundle, enc.reasonReference?.[0].reference)).toBe(
+ `Condition/${c.id}`,
+ );
const composition = bundle.entry[0].resource;
const titles =
diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts
new file mode 100644
index 000000000..8877f7dad
--- /dev/null
+++ b/src/lib/fhir/__tests__/conformance.test.ts
@@ -0,0 +1,516 @@
+/**
+ * FHIR R4 conformance of the document Bundle and the emitters behind it.
+ *
+ * Every assertion here stands for a statement the export makes to a clinician's
+ * system: that the document has an identity, that a reference it prints
+ * resolves, that a gender it names is the one the person recorded, that a
+ * period aggregate is not passed off as an instant reading, and that a coded
+ * UCUM symbol is one a receiver can parse.
+ */
+import { describe, expect, it } from "vitest";
+
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
+import { administrativeGender } from "@/lib/fhir/resources";
+import { isUcumCode, ucumQuantity } from "@/lib/fhir/ucum";
+import { allSignals } from "@/lib/signals/registry";
+import type {
+ FhirBundle,
+ FhirCoverage,
+ FhirObservation,
+ FhirResource,
+} from "@/lib/fhir/types";
+
+const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
+
+const UUID_V4 =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function makeData(overrides?: Partial): DoctorReportData {
+ return {
+ period: {
+ days: 90,
+ since: "2026-02-02T00:00:00.000Z",
+ start: "2026-02-02T00:00:00.000Z",
+ end: "2026-05-03T12:00:00.000Z",
+ },
+ patient: {
+ username: "sample-user",
+ dateOfBirth: "1985-06-15T00:00:00.000Z",
+ gender: "MALE",
+ heightCm: 182,
+ fullName: "Sample Patient",
+ insurerName: "Example Insurer",
+ insurerIkNumber: "101234567",
+ },
+ practiceName: null,
+ measurements: {
+ WEIGHT: [{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }],
+ BLOOD_PRESSURE_SYS: [
+ { value: 120, measuredAt: "2026-04-30T08:00:00.000Z" },
+ ],
+ BLOOD_PRESSURE_DIA: [
+ { value: 78, measuredAt: "2026-04-30T08:00:00.000Z" },
+ ],
+ },
+ stats: {},
+ glucoseStats: {
+ FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
+ },
+ glucoseRanges: {},
+ glucoseClinical: computeGlucoseClinicalMetrics([], {
+ now: FIXED_NOW,
+ }),
+ glucoseUnit: "mg/dL",
+ bmi: 24.1,
+ compliance: {
+ "Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 },
+ },
+ medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }],
+ medicationAdministrations: [
+ {
+ medicationName: "Example Drug",
+ effectiveAt: "2026-04-30T08:00:00.000Z",
+ status: "completed",
+ doseText: "5mg",
+ dose: { value: 5, unit: "mg" },
+ injectionSite: null,
+ atcCode: null,
+ rxNormCode: null,
+ deliveryForm: "ORAL",
+ },
+ ],
+ labResults: [
+ {
+ analyte: "HDL",
+ unit: "mg/dL",
+ value: 55,
+ valueText: null,
+ panel: null,
+ takenAt: "2026-04-20T08:00:00.000Z",
+ referenceLow: 40,
+ referenceHigh: 80,
+ },
+ ],
+ illnessEpisodes: [
+ {
+ label: "Erkältung",
+ type: "INFECTION",
+ lifecycle: "ACUTE",
+ onsetAt: "2026-04-01T00:00:00.000Z",
+ resolvedAt: "2026-04-10T00:00:00.000Z",
+ },
+ ],
+ mood: { avg: 3.8, count: 20 },
+ glp1: null,
+ ...overrides,
+ } as DoctorReportData;
+}
+
+function fullBundle(overrides?: Partial): FhirBundle {
+ return buildFhirDocumentBundle(
+ makeData(overrides),
+ { insuranceNumber: "A123456780" },
+ FIXED_NOW,
+ {},
+ {
+ allergies: [
+ {
+ id: "a1",
+ substance: "Penicillin",
+ category: "MEDICATION",
+ type: "ALLERGY",
+ status: "ACTIVE",
+ severity: "SEVERE",
+ reaction: "Rash",
+ onsetAt: null,
+ note: null,
+ },
+ ] as never,
+ familyHistory: [
+ {
+ id: "f1",
+ relationship: "MOTHER",
+ condition: "Hypertension",
+ ageAtOnset: 50,
+ note: null,
+ },
+ ] as never,
+ },
+ );
+}
+
+/** Every `{ reference: … }` string anywhere in the serialised bundle. */
+function allReferences(value: unknown, out: string[] = []): string[] {
+ if (Array.isArray(value)) {
+ for (const item of value) allReferences(item, out);
+ return out;
+ }
+ if (value === null || typeof value !== "object") return out;
+ for (const [key, child] of Object.entries(value as Record)) {
+ if (key === "reference" && typeof child === "string") out.push(child);
+ else allReferences(child, out);
+ }
+ return out;
+}
+
+/** Every `text` string anywhere in the serialised bundle. */
+function allTexts(value: unknown, out: string[] = []): string[] {
+ if (Array.isArray(value)) {
+ for (const item of value) allTexts(item, out);
+ return out;
+ }
+ if (value === null || typeof value !== "object") return out;
+ for (const [key, child] of Object.entries(value as Record)) {
+ if (key === "text" && typeof child === "string") out.push(child);
+ else allTexts(child, out);
+ }
+ return out;
+}
+
+function resourcesOf(bundle: FhirBundle): FhirResource[] {
+ return bundle.entry.map((e) => e.resource);
+}
+
+// ── Test 14 — document identity, entry identity, reference resolution ─────
+
+describe("document Bundle — identity and resolvability", () => {
+ it("carries a Bundle.identifier with a system and a UUIDv4 value", () => {
+ const bundle = fullBundle();
+ expect(bundle.identifier.system).toBe(
+ "https://healthlog.dev/fhir/bundle-id",
+ );
+ expect(bundle.identifier.value).toMatch(UUID_V4);
+ });
+
+ it("mints a fresh document identifier per build", () => {
+ expect(fullBundle().identifier.value).not.toBe(
+ fullBundle().identifier.value,
+ );
+ });
+
+ it("gives every entry a distinct urn:uuid fullUrl over a UUIDv4", () => {
+ const bundle = fullBundle();
+ expect(bundle.entry.length).toBeGreaterThan(5);
+ for (const entry of bundle.entry) {
+ expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true);
+ expect(entry.fullUrl.slice("urn:uuid:".length)).toMatch(UUID_V4);
+ }
+ const urls = new Set(bundle.entry.map((e) => e.fullUrl));
+ expect(urls.size).toBe(bundle.entry.length);
+ });
+
+ it("keeps the human-readable resource ids", () => {
+ const ids = resourcesOf(fullBundle()).map((r) => r.id);
+ expect(ids).toContain("patient-1");
+ expect(ids).toContain("composition-1");
+ expect(ids).toContain("obs-1");
+ });
+
+ it("resolves every reference in the bundle against an entry fullUrl", () => {
+ const bundle = fullBundle();
+ const urls = new Set(bundle.entry.map((e) => e.fullUrl));
+ const containedIds = new Set(
+ resourcesOf(bundle).flatMap((r) =>
+ "contained" in r && Array.isArray(r.contained)
+ ? r.contained.map((c) => `#${c.id}`)
+ : [],
+ ),
+ );
+ const references = allReferences(bundle);
+ expect(references.length).toBeGreaterThan(5);
+ for (const reference of references) {
+ const resolvable = reference.startsWith("#")
+ ? containedIds.has(reference)
+ : urls.has(reference);
+ expect(resolvable, `unresolvable reference: ${reference}`).toBe(true);
+ }
+ });
+
+ it("carries the Device the Composition names as its author", () => {
+ const bundle = fullBundle();
+ const composition = bundle.entry[0].resource;
+ if (composition.resourceType !== "Composition") {
+ throw new Error("expected a leading Composition");
+ }
+ const authorRef = composition.author[0].reference;
+ const author = bundle.entry.find((e) => e.fullUrl === authorRef)?.resource;
+ expect(author?.resourceType).toBe("Device");
+ if (author?.resourceType !== "Device") throw new Error("not a Device");
+ expect(author.manufacturer).toBe("HealthLog");
+ expect(author.deviceName?.[0].name).toBe("HealthLog");
+ expect(author.version?.[0].value.length).toBeGreaterThan(0);
+ });
+
+ it("emits no Coverage without a payor", () => {
+ for (const bundle of [
+ fullBundle(),
+ fullBundle({
+ patient: {
+ username: "u",
+ dateOfBirth: null,
+ gender: null,
+ heightCm: null,
+ },
+ }),
+ ]) {
+ for (const resource of resourcesOf(bundle)) {
+ if (resource.resourceType !== "Coverage") continue;
+ const coverage = resource as FhirCoverage;
+ expect(coverage.payor.length).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ it("emits no empty-string text anywhere in the serialised bundle", () => {
+ const bundle = fullBundle({
+ // A qualitative lab row with neither a number nor a result term.
+ labResults: [
+ {
+ analyte: "Borrelia IgG",
+ unit: "",
+ value: null,
+ valueText: " ",
+ panel: null,
+ takenAt: "2026-04-20T08:00:00.000Z",
+ referenceLow: null,
+ referenceHigh: null,
+ },
+ ] as never,
+ });
+ for (const text of allTexts(bundle)) {
+ expect(text.trim().length, "empty text in the bundle").toBeGreaterThan(0);
+ }
+ // …and the value-less row produced no Observation at all.
+ const analytes = resourcesOf(bundle)
+ .filter((r): r is FhirObservation => r.resourceType === "Observation")
+ .map((o) => o.code.text);
+ expect(analytes).not.toContain("Borrelia IgG");
+ });
+
+ it("reaches Coverage, the Encounters and the DiagnosticReport from the Composition", () => {
+ const bundle = fullBundle();
+ const composition = bundle.entry[0].resource;
+ if (composition.resourceType !== "Composition") {
+ throw new Error("expected a leading Composition");
+ }
+ const reachable = new Set(
+ (composition.section ?? []).flatMap((s) =>
+ (s.entry ?? []).map((e) => e.reference),
+ ),
+ );
+ const reachableTypes = new Set(
+ bundle.entry
+ .filter((e) => reachable.has(e.fullUrl))
+ .map((e) => e.resource.resourceType),
+ );
+ expect(reachableTypes.has("Coverage")).toBe(true);
+ expect(reachableTypes.has("Encounter")).toBe(true);
+ expect(reachableTypes.has("DiagnosticReport")).toBe(true);
+ // …and the subject is NOT restated as a section entry.
+ expect(reachableTypes.has("Patient")).toBe(false);
+ });
+});
+
+// ── Test 15 — administrative gender ──────────────────────────────────────
+
+describe("Patient.gender", () => {
+ it("maps the recorded value and omits everything else", () => {
+ expect(administrativeGender("MALE")).toBe("male");
+ expect(administrativeGender("FEMALE")).toBe("female");
+ expect(administrativeGender("OTHER")).toBe("other");
+ expect(administrativeGender(null)).toBeNull();
+ expect(administrativeGender(undefined)).toBeNull();
+ expect(administrativeGender("")).toBeNull();
+ expect(administrativeGender("DIVERS")).toBeNull();
+ });
+
+ it("never states a gender the person did not record", () => {
+ const cases: Array<[string | null, string | undefined]> = [
+ ["MALE", "male"],
+ ["FEMALE", "female"],
+ ["OTHER", "other"],
+ [null, undefined],
+ ["nonbinary", undefined],
+ ];
+ for (const [stored, expected] of cases) {
+ const bundle = fullBundle({
+ patient: {
+ username: "u",
+ dateOfBirth: null,
+ gender: stored,
+ heightCm: null,
+ },
+ });
+ const patient = resourcesOf(bundle).find(
+ (r) => r.resourceType === "Patient",
+ );
+ if (patient?.resourceType !== "Patient") throw new Error("no Patient");
+ expect(patient.gender, `stored ${String(stored)}`).toBe(expected);
+ }
+ });
+
+ it("never emits `unknown` — an unrecorded field is absent, not asked-and-unknown", () => {
+ for (const stored of [null, "OTHER", "nope"]) {
+ expect(administrativeGender(stored)).not.toBe("unknown");
+ }
+ });
+});
+
+// ── Test 16 — period aggregates carry effectivePeriod ────────────────────
+
+describe("Observation.effective[x]", () => {
+ const PERIOD_DERIVED = [
+ "Body mass index (BMI) [Ratio]",
+ "Fasting glucose [Mass/volume] in Serum or Plasma",
+ "Mood (average over period)",
+ "Medication adherence — Example Drug",
+ ];
+
+ it("states a window for a value derived over the window, not an instant", () => {
+ const bundle = fullBundle();
+ const observations = resourcesOf(bundle).filter(
+ (r): r is FhirObservation => r.resourceType === "Observation",
+ );
+ for (const text of PERIOD_DERIVED) {
+ const observation = observations.find((o) => o.code.text === text);
+ expect(observation, `missing Observation: ${text}`).toBeDefined();
+ expect(observation?.effectivePeriod).toEqual({
+ start: "2026-02-02T00:00:00.000Z",
+ end: "2026-05-03T12:00:00.000Z",
+ });
+ expect(observation?.effectiveDateTime).toBeUndefined();
+ }
+ });
+
+ it("keeps the reading instant for a single reading", () => {
+ const bundle = fullBundle();
+ const weight = resourcesOf(bundle)
+ .filter((r): r is FhirObservation => r.resourceType === "Observation")
+ .find((o) => o.code.coding?.[0].code === "29463-7");
+ expect(weight?.effectiveDateTime).toBe("2026-04-30T08:00:00.000Z");
+ expect(weight?.effectivePeriod).toBeUndefined();
+ });
+
+ it("states a window for every cycle figure", () => {
+ const bundle = fullBundle({
+ cycle: {
+ lastPeriodStart: "2026-04-20",
+ recentCycles: [],
+ observedCycleCount: 3,
+ averageCycleLengthDays: 27.5,
+ cycleLengthVariabilityDays: 0.5,
+ averagePeriodLengthDays: 5,
+ currentPhase: "LUTEAL",
+ },
+ });
+ const cycleObservations = resourcesOf(bundle).filter(
+ (r): r is FhirObservation =>
+ r.resourceType === "Observation" && r.id.startsWith("obs-cycle-"),
+ );
+ expect(cycleObservations).toHaveLength(4);
+ for (const observation of cycleObservations) {
+ expect(observation.effectivePeriod?.end).toBe("2026-05-03T12:00:00.000Z");
+ expect(observation.effectiveDateTime).toBeUndefined();
+ }
+ });
+});
+
+// ── W1 — the screener totals reach FHIR at all ───────────────────────────
+
+describe("WHO-5 and SCI totals", () => {
+ it("emit a text-only survey Observation rather than being dropped", () => {
+ const bundle = fullBundle({
+ measurements: {
+ WHO5_SCORE: [{ value: 68, measuredAt: "2026-04-28T08:00:00.000Z" }],
+ SCI_SCORE: [{ value: 24, measuredAt: "2026-04-28T08:00:00.000Z" }],
+ },
+ });
+ const observations = resourcesOf(bundle).filter(
+ (r): r is FhirObservation => r.resourceType === "Observation",
+ );
+ for (const [display, value] of [
+ ["WHO-5 well-being index total score", 68],
+ ["Sleep Condition Indicator total score", 24],
+ ] as const) {
+ const observation = observations.find((o) => o.code.text === display);
+ expect(observation, display).toBeDefined();
+ // No verified LOINC exists for either total, so no code is invented.
+ expect(observation?.code.coding).toBeUndefined();
+ expect(observation?.category?.[0].coding?.[0].code).toBe("survey");
+ expect(observation?.valueQuantity?.value).toBe(value);
+ expect(observation?.valueQuantity?.code).toBe("{score}");
+ }
+ });
+});
+
+// ── Test 19 — UCUM allowlist over every registry facet ───────────────────
+
+describe("UCUM coding", () => {
+ it("either validates a registry unit or emits it uncoded", () => {
+ const units = allSignals()
+ .map((signal) => signal.fhir?.unit)
+ .filter((unit): unit is string => typeof unit === "string");
+ expect(units.length).toBeGreaterThan(20);
+ for (const unit of units) {
+ // The contract: the display unit always goes out, and `system` + `code`
+ // follow exactly when the string is a UCUM expression. No unchecked
+ // string ever reaches `Quantity.code`.
+ const quantity = ucumQuantity(1, unit);
+ expect(quantity.unit, unit).toBe(unit);
+ if (isUcumCode(unit)) {
+ expect(quantity.system, unit).toBe("http://unitsofmeasure.org");
+ expect(quantity.code, unit).toBe(unit);
+ } else {
+ expect(quantity.system, unit).toBeUndefined();
+ expect(quantity.code, unit).toBeUndefined();
+ }
+ }
+ // The known non-UCUM display label in the registry stays uncoded.
+ expect(units).toContain("dB[A]");
+ expect(isUcumCode("dB[A]")).toBe(false);
+ // …while the ordinary ones validate.
+ for (const unit of ["kg", "%", "{score}", "Cel", "/min", "kg/m2"]) {
+ expect(isUcumCode(unit), unit).toBe(true);
+ }
+ });
+
+ it("stamps system and code together, or neither", () => {
+ const bundle = fullBundle();
+ const quantities = resourcesOf(bundle)
+ .filter((r): r is FhirObservation => r.resourceType === "Observation")
+ .flatMap((o) => [
+ o.valueQuantity,
+ ...(o.component ?? []).map((c) => c.valueQuantity),
+ ...(o.referenceRange ?? []).flatMap((r) => [r.low, r.high]),
+ ])
+ .filter((q) => q !== undefined);
+ expect(quantities.length).toBeGreaterThan(5);
+ for (const quantity of quantities) {
+ if (quantity!.code !== undefined) {
+ expect(quantity!.system).toBe("http://unitsofmeasure.org");
+ }
+ expect(quantity!.unit).toBeDefined();
+ }
+ });
+
+ it("carries the bounds in the same coding as the value beside them", () => {
+ const bundle = fullBundle();
+ const hdl = resourcesOf(bundle)
+ .filter((r): r is FhirObservation => r.resourceType === "Observation")
+ .find((o) => o.code.text === "HDL");
+ expect(hdl?.valueQuantity?.code).toBe("mg/dL");
+ expect(hdl?.referenceRange?.[0].low).toEqual(
+ hdl?.valueQuantity && {
+ value: 40,
+ unit: "mg/dL",
+ system: "http://unitsofmeasure.org",
+ code: "mg/dL",
+ },
+ );
+ expect(hdl?.referenceRange?.[0].high?.system).toBe(
+ "http://unitsofmeasure.org",
+ );
+ });
+});
diff --git a/src/lib/fhir/__tests__/rest-module-backstop.test.ts b/src/lib/fhir/__tests__/rest-module-backstop.test.ts
index e048ee6c5..ec4bab3d0 100644
--- a/src/lib/fhir/__tests__/rest-module-backstop.test.ts
+++ b/src/lib/fhir/__tests__/rest-module-backstop.test.ts
@@ -29,12 +29,20 @@ vi.mock("@/lib/doctor-report-data", () => ({
normaliseDateRange: vi.fn(() => ({ from: new Date(0), to: new Date() })),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
+// The REST face replays the owner's saved selection; this test is about the
+// module backstop, so the selection is fixed to one that names the insurance
+// leaf — otherwise the identity assertion below would be testing the
+// selection resolver instead.
+vi.mock("@/lib/report-selection/saved-profile", () => ({
+ resolveSavedSelection: vi.fn(async () => selectionFromLeaves(["INSURANCE"])),
+}));
import { loadFhirContext } from "@/lib/fhir/rest";
import { MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
function setDoctorReportModule(enabled: boolean): void {
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts
index a30a9e99b..87c963081 100644
--- a/src/lib/fhir/build-bundle.ts
+++ b/src/lib/fhir/build-bundle.ts
@@ -4,25 +4,25 @@
* Pure function: takes the SAME `DoctorReportData` the PDF renderer consumes
* (assembled by `collectDoctorReportData`) plus the decrypted patient
* identity, and emits a `Bundle` of `type: "document"` — a leading
- * `Composition` "cover page", the `Patient`, then one `Observation` per
- * selected vital / glucose context / mood + one `MedicationStatement` per
- * active medication.
+ * `Composition` "cover page", the `Device` that authored it, the `Patient`,
+ * then one `Observation` per selected vital / glucose context / lab / mood,
+ * one `MedicationStatement` per active medication, and the structured records.
*
* Because it reuses the PDF aggregator, the FHIR export and the PDF describe
* identical numbers by construction (the source-of-truth property the two
* PDF endpoints already share).
*
- * v1.11.0 — the per-resource emitters (Observation / MedicationStatement /
- * MedicationAdministration / Patient / Coverage, with their LOINC/ATC/SNOMED/
- * UCUM codings + the `survey`-category wellness split) live in the shared
- * `./resources` module so the FHIR REST search routes reuse the SAME coding
- * logic. This builder composes them into the document Bundle.
+ * The per-resource emitters live in `./resources`, shared with the FHIR REST
+ * routes; this builder composes them into the document Bundle, mints the entry
+ * identities, and rewrites every internal reference onto them.
*
* No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
* (`./types`), matching the project's "hand-rolled over the documented wire"
* convention. The `Composition.text` narrative is escaped plain text, never
* user-supplied HTML (no markdown library, no `dangerouslySetInnerHTML`).
*/
+import { randomUUID } from "node:crypto";
+
import type { DoctorReportData } from "@/lib/doctor-report-data";
import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto";
import {
@@ -46,14 +46,16 @@ import type {
FhirBundle,
FhirBundleEntry,
FhirComposition,
+ FhirDevice,
FhirDiagnosticReport,
+ FhirObservation,
FhirReference,
FhirResource,
} from "@/lib/fhir/types";
// Re-export the shared coding constants + option types so existing importers
// (capabilities + health-record routes) keep their `@/lib/fhir/build-bundle`
-// import path. The single source of truth now lives in `./resources`.
+// import path. The single source of truth lives in `./resources`.
export {
ATC_SYSTEM,
SNOMED_SYSTEM,
@@ -61,6 +63,12 @@ export {
} from "@/lib/fhir/resources";
export type { FhirBuildOptions, FhirPatientIdentity };
+/** Namespace for the document's own identifier (`bdl-9`). */
+const BUNDLE_IDENTIFIER_SYSTEM = "https://healthlog.dev/fhir/bundle-id";
+
+/** Resource id of the authoring `Device` the Composition names. */
+const AUTHOR_DEVICE_ID = "healthlog";
+
/** Escape the five XML-significant characters for the xhtml narrative. */
function escapeXml(value: string): string {
return value
@@ -82,6 +90,68 @@ export interface FhirRecordInputs {
familyHistory?: FamilyHistoryEntryDTO[];
}
+/**
+ * The running build's version, for the authoring Device. Same resolution order
+ * as `/api/version`: the image build arg wins so the exported document names
+ * the release that produced it, with the package version as the local-dev
+ * fallback.
+ */
+function runningVersion(): string {
+ return (
+ process.env.NEXT_PUBLIC_APP_VERSION?.trim() ||
+ process.env.npm_package_version?.trim() ||
+ "unknown"
+ );
+}
+
+/**
+ * Return a copy of `value` in which every relative reference
+ * (`Observation/obs-1`) names the entry identity that actually carries the
+ * resource.
+ *
+ * `bdl-7` resolves an intra-document reference against `Bundle.entry.fullUrl`,
+ * and the fullUrls are URNs — so a relative reference resolves against the
+ * base of the RETRIEVING server, i.e. somewhere else entirely, or nowhere. One
+ * total walk over the assembled resources is what makes that impossible to get
+ * partially right: an emitter that grows a new reference is covered the day it
+ * lands, without a second place to remember.
+ *
+ * Copies rather than mutates: the emitters share module-level reference
+ * constants (one `{ reference: "Patient/patient-1" }` object is the `subject`
+ * of every resource in the tree), and rewriting in place would rewrite them
+ * for every later build in the process.
+ *
+ * Contained `#`-refs (the Coverage payor Organization) are left alone — they
+ * already resolve inside their own resource.
+ */
+function withResolvedReferences(
+ value: T,
+ urnByReference: ReadonlyMap,
+): T {
+ if (Array.isArray(value)) {
+ return value.map((item) =>
+ withResolvedReferences(item, urnByReference),
+ ) as unknown as T;
+ }
+ if (value === null || typeof value !== "object") return value;
+
+ const out: Record = {};
+ for (const [key, child] of Object.entries(value as Record)) {
+ out[key] =
+ key === "reference" && typeof child === "string"
+ ? (urnByReference.get(child) ?? child)
+ : withResolvedReferences(child, urnByReference);
+ }
+ return out as T;
+}
+
+/** True for an Observation the vital-signs panel may legitimately claim. */
+function isVitalSignsObservation(observation: FhirObservation): boolean {
+ return (observation.category ?? []).some((concept) =>
+ (concept.coding ?? []).some((coding) => coding.code === "vital-signs"),
+ );
+}
+
/**
* Build a FHIR R4 document Bundle from the aggregated report data.
*
@@ -97,29 +167,58 @@ export function buildFhirDocumentBundle(
const patientRef: FhirReference = {
reference: `Patient/${PATIENT_RESOURCE_ID}`,
};
+ const deviceRef: FhirReference = {
+ reference: `Device/${AUTHOR_DEVICE_ID}`,
+ display: "HealthLog",
+ };
const entries: FhirBundleEntry[] = [];
const observationRefs: FhirReference[] = [];
+ const vitalSignsObservationRefs: FhirReference[] = [];
const cycleObservationRefs: FhirReference[] = [];
const medicationRefs: FhirReference[] = [];
const administrationRefs: FhirReference[] = [];
const conditionRefs: FhirReference[] = [];
+ const encounterRefs: FhirReference[] = [];
const allergyRefs: FhirReference[] = [];
const familyHistoryRefs: FhirReference[] = [];
+ const coverageRefs: FhirReference[] = [];
+
+ /**
+ * Mint the entry identity. `fullUrl` is a fresh RFC-4122 UUID per entry —
+ * `resource.id` stays the human-readable `obs-3` a maintainer can follow
+ * through a diff, while the identity a receiver resolves against is unique
+ * across every document ever exported.
+ */
+ const push = (resource: FhirResource): void => {
+ entries.push({ fullUrl: `urn:uuid:${randomUUID()}`, resource });
+ };
+
+ // --- Device (the Composition's author) ---------------------------------
+ const device: FhirDevice = {
+ resourceType: "Device",
+ id: AUTHOR_DEVICE_ID,
+ manufacturer: "HealthLog",
+ deviceName: [{ name: "HealthLog", type: "manufacturer-name" }],
+ version: [{ value: runningVersion() }],
+ };
+ push(device);
// --- Patient -----------------------------------------------------------
- const patient = patientResource(data, identity);
- entries.push({ fullUrl: `urn:uuid:${patient.id}`, resource: patient });
+ push(patientResource(data, identity));
// --- Coverage (insurer; sits right after the Patient) ------------------
const coverage = coverageResource(data, identity);
if (coverage) {
- entries.push({ fullUrl: `urn:uuid:${coverage.id}`, resource: coverage });
+ push(coverage);
+ coverageRefs.push({ reference: `Coverage/${coverage.id}` });
}
// --- Observations (vital / activity / lab / survey, in canonical order)-
- for (const obs of observationsFromReportData(data, identity, options)) {
- entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
- observationRefs.push({ reference: `Observation/${obs.id}` });
+ for (const obs of observationsFromReportData(data)) {
+ push(obs);
+ const ref: FhirReference = { reference: `Observation/${obs.id}` };
+ observationRefs.push(ref);
+ if (isVitalSignsObservation(obs)) vitalSignsObservationRefs.push(ref);
}
// --- Cycle / reproductive-health Observations (opt-in only) -----------
@@ -127,19 +226,19 @@ export function buildFhirDocumentBundle(
// so a reproductive-health finding is never mixed into the vital-signs
// panel. Absent unless the user toggled the cycle section AND has data.
for (const obs of cycleObservationsFromReportData(data)) {
- entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
+ push(obs);
cycleObservationRefs.push({ reference: `Observation/${obs.id}` });
}
// --- MedicationStatement per active medication -------------------------
for (const stmt of medicationStatementsFromReportData(data, options)) {
- entries.push({ fullUrl: `urn:uuid:${stmt.id}`, resource: stmt });
+ push(stmt);
medicationRefs.push({ reference: `MedicationStatement/${stmt.id}` });
}
// --- MedicationAdministration per acted intake -------------------------
for (const admin of medicationAdministrationsFromReportData(data, options)) {
- entries.push({ fullUrl: `urn:uuid:${admin.id}`, resource: admin });
+ push(admin);
administrationRefs.push({
reference: `MedicationAdministration/${admin.id}`,
});
@@ -152,24 +251,25 @@ export function buildFhirDocumentBundle(
// `code.text`) plus a bounding Encounter that references it.
const { conditions, encounters } = conditionsFromReportData(data);
for (const condition of conditions) {
- entries.push({ fullUrl: `urn:uuid:${condition.id}`, resource: condition });
+ push(condition);
conditionRefs.push({ reference: `Condition/${condition.id}` });
}
for (const encounter of encounters) {
- entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter });
+ push(encounter);
+ encounterRefs.push({ reference: `Encounter/${encounter.id}` });
}
// --- AllergyIntolerance per recorded allergy (v1.25) -------------------
// Always-available structured records (not time-windowed). Patient-reported
// (verificationStatus unconfirmed); the substance rides `code.text`.
for (const allergy of allergyIntoleranceResources(records.allergies ?? [])) {
- entries.push({ fullUrl: `urn:uuid:${allergy.id}`, resource: allergy });
+ push(allergy);
allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` });
}
// --- FamilyMemberHistory per recorded entry (v1.25) -------------------
for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) {
- entries.push({ fullUrl: `urn:uuid:${fmh.id}`, resource: fmh });
+ push(fmh);
familyHistoryRefs.push({ reference: `FamilyMemberHistory/${fmh.id}` });
}
@@ -190,6 +290,32 @@ export function buildFhirDocumentBundle(
: []),
].join(" ");
+ const diagnosticReport: FhirDiagnosticReport = {
+ resourceType: "DiagnosticReport",
+ id: "diagnostic-report-1",
+ status: "final",
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: "85353-1",
+ display:
+ "Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
+ },
+ ],
+ text: "Vital signs panel",
+ },
+ subject: patientRef,
+ effectivePeriod: { start: data.period.start, end: data.period.end },
+ // A vital-signs panel reports vital signs. Routing labs, adherence rates
+ // and survey scores through it would let a receiver read a questionnaire
+ // total as a vital sign because the panel code said so.
+ result: vitalSignsObservationRefs,
+ };
+ const diagnosticReportRef: FhirReference = {
+ reference: `DiagnosticReport/${diagnosticReport.id}`,
+ };
+
const composition: FhirComposition = {
resourceType: "Composition",
id: "composition-1",
@@ -206,30 +332,30 @@ export function buildFhirDocumentBundle(
},
subject: patientRef,
date: now.toISOString(),
- author: [{ reference: "Device/healthlog", display: "HealthLog" }],
+ author: [deviceRef],
title: "Health Record",
- // v1.9.0 — top-level document narrative (Coverage nit 2). Reuses the
- // same escaped plain-text summary the Vital-signs section carries, so
- // a strict US-Core-style validator sees a `Composition.text`.
+ // v1.9.0 — top-level document narrative. Section narrative is already
+ // present; a strict US-Core-style validator additionally expects a
+ // `Composition.text`. Reuses the same escaped plain-text summary.
text: {
status: "generated",
div: `
${escapeXml(narrativeText)}
`,
},
section: [
{
- // Vital-signs section carries the narrative + every Observation ref,
- // matching the iOS Bundle graph ("Vital signs" + "Medications").
+ // Vital-signs section carries the narrative + every Observation ref.
+ // NOT the Patient: `Composition.subject` above already names the
+ // person the whole document is about, and a subject listed as a
+ // section entry reads as a finding.
title: "Vital signs",
text: {
status: "generated",
div: `
${escapeXml(narrativeText)}
`,
},
- entry: [patientRef, ...observationRefs],
+ entry: [...observationRefs, diagnosticReportRef],
},
// Medications section carries both the active-medication
- // statements and the per-dose administration records (v1.9.0), so
- // the iOS two-section graph ("Vital signs" + "Medications") is
- // preserved without adding a third section.
+ // statements and the per-dose administration records (v1.9.0).
...(medicationRefs.length > 0 || administrationRefs.length > 0
? [
{
@@ -248,14 +374,15 @@ export function buildFhirDocumentBundle(
},
]
: []),
- // v1.18.1 — opt-in illness/condition section. Only present when the
- // illness module surfaced episodes. Lists the Condition resources (the
- // Encounter rides each Condition's reasonReference).
+ // v1.18.1 — opt-in illness/condition section. Lists the Condition
+ // resources AND the Encounters that bound them: an Encounter reachable
+ // only from a Condition's `reasonReference` is not reachable from the
+ // document at all.
...(conditionRefs.length > 0
? [
{
title: "Conditions",
- entry: conditionRefs,
+ entry: [...conditionRefs, ...encounterRefs],
},
]
: []),
@@ -278,50 +405,48 @@ export function buildFhirDocumentBundle(
},
]
: []),
+ // The insurance relationship. A Coverage no section reaches is a
+ // resource the document carries and never mentions.
+ ...(coverageRefs.length > 0
+ ? [
+ {
+ title: "Insurance",
+ entry: coverageRefs,
+ },
+ ]
+ : []),
],
};
- // --- DiagnosticReport (vital-signs panel; routes all Observations) -----
- // Last entry in the Bundle, matching the iOS Bundle graph. `result[]`
- // references every emitted Observation.
- const diagnosticReport: FhirDiagnosticReport = {
- resourceType: "DiagnosticReport",
- id: "diagnostic-report-1",
- status: "final",
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: "85353-1",
- display:
- "Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
- },
- ],
- text: "Vital signs panel",
- },
- subject: patientRef,
- effectivePeriod: { start: data.period.start, end: data.period.end },
- result: observationRefs,
- };
-
// The Composition must be the FIRST entry in a document Bundle; the
// DiagnosticReport is the LAST.
const orderedEntries: FhirBundleEntry[] = [
- {
- fullUrl: `urn:uuid:${composition.id}`,
- resource: composition as FhirResource,
- },
+ { fullUrl: `urn:uuid:${randomUUID()}`, resource: composition },
...entries,
- {
- fullUrl: `urn:uuid:${diagnosticReport.id}`,
- resource: diagnosticReport as FhirResource,
- },
+ { fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport },
];
+ // Every entry identity is known only now, so the reference rewrite runs once
+ // over the finished set.
+ const urnByReference = new Map(
+ orderedEntries.map((entry) => [
+ `${entry.resource.resourceType}/${entry.resource.id}`,
+ entry.fullUrl,
+ ]),
+ );
+ const resolvedEntries: FhirBundleEntry[] = orderedEntries.map((entry) => ({
+ fullUrl: entry.fullUrl,
+ resource: withResolvedReferences(entry.resource, urnByReference),
+ }));
+
return {
resourceType: "Bundle",
type: "document",
+ identifier: {
+ system: BUNDLE_IDENTIFIER_SYSTEM,
+ value: randomUUID(),
+ },
timestamp: now.toISOString(),
- entry: orderedEntries,
+ entry: resolvedEntries,
};
}
diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts
deleted file mode 100644
index 1edbd0932..000000000
--- a/src/lib/fhir/resources.ts
+++ /dev/null
@@ -1,1143 +0,0 @@
-/**
- * v1.11.0 — shared per-resource FHIR R4 emitters.
- *
- * The Observation / MedicationStatement / MedicationAdministration / Patient /
- * Coverage builders (with their LOINC/ATC/SNOMED/UCUM codings + the `survey`
- * wellness split) live here as small pure functions over the SAME
- * `DoctorReportData` the document-bundle builder consumes.
- *
- * Two callers share these emitters: `buildFhirDocumentBundle` composes them
- * into a `type: "document"` Bundle, and the FHIR REST search routes wrap them
- * in a `type: "searchset"` Bundle. Keeping the coding logic in one place means
- * the LOINC/UCUM/ATC mapping has exactly one home — the document export and
- * the REST face can never drift apart.
- *
- * No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
- * (`./types`), matching the project's "hand-rolled over the documented wire"
- * convention. All text is escaped plain text; never user-supplied HTML
- * (no markdown library, no `dangerouslySetInnerHTML`).
- */
-import {
- adherenceRatePercent,
- type DoctorReportData,
-} from "@/lib/doctor-report-data";
-import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose";
-import {
- LOINC_SYSTEM,
- HEALTHKIT_CODESYSTEM,
- UCUM_SYSTEM,
- MEASUREMENT_LOINC,
- BP_PANEL_LOINC,
- BP_SYS_LOINC,
- BP_DIA_LOINC,
- BP_UNIT,
- GLUCOSE_LOINC,
- GLUCOSE_TIR_LOINC,
- GLUCOSE_GMI_LOINC,
- GLUCOSE_MEAN_LOINC,
- GLUCOSE_EA1C_LOINC,
- MEDICATION_ADHERENCE_LOINC,
- MOOD_LOINC,
- LMP_LOINC,
- CYCLE_LENGTH_LOINC,
- PERIOD_LENGTH_LOINC,
- type LoincMapping,
-} from "@/lib/fhir/loinc-map";
-import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
-import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
-import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative";
-import type {
- FhirCodeableConcept,
- FhirObservation,
- FhirMedicationStatement,
- FhirMedicationAdministration,
- FhirDosage,
- FhirPatient,
- FhirCoverage,
- FhirCondition,
- FhirEncounter,
- FhirOrganization,
- FhirReference,
-} from "@/lib/fhir/types";
-
-/** Patient identity not carried in `DoctorReportData` (KVNR is encrypted). */
-export interface FhirPatientIdentity {
- /** German KVNR (decrypted by the route). */
- insuranceNumber: string | null;
-}
-
-/** KVNR identifier namespace per the gematik SID. */
-const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10";
-
-/** German insurer institution-number (IKNR) identifier namespace. */
-const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
-
-/**
- * v1.9.0 — drug-coding system URIs. ATC is the portable WHO default
- * (the iOS export emits the identical URI); RxNorm is the secondary US
- * coding. Both are additive `coding[]` entries on the same concept; the
- * free-text `.text` (the user's medication name) stays the anchor.
- */
-export const ATC_SYSTEM = "http://www.whocc.no/atc";
-/**
- * German national ATC URI maintained by the BfArM. Same ATC classification
- * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
- * a replacement) when a German-region export is requested, so the WHO entry
- * stays first and byte-identical for every consumer.
- */
-const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
-const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
-/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
-export const SNOMED_SYSTEM = "http://snomed.info/sct";
-
-/**
- * The app locales for which a health-record export defaults `germanAtc` on
- * (the additive BfArM ATC coding). The export route derives the flag from
- * the user's locale against this set; the capabilities endpoint surfaces it
- * verbatim so a client can predict the coding without a round-trip. Keeping
- * it here — beside the BfArM URI it gates — makes the two move together.
- */
-export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const;
-
-/** Options threaded from the export route into the emitters. Additive, all defaulted. */
-export interface FhirBuildOptions {
- /**
- * When true, additionally emit the German BfArM ATC URI alongside the WHO
- * entry on each medication concept. The WHO coding stays first and
- * byte-identical; this only appends a second URI for the same leaf code.
- * Defaults off; the route turns it on for a German-region export.
- */
- germanAtc?: boolean;
-}
-
-/**
- * Build a `medicationCodeableConcept` from a medication's free-text name
- * plus its optional user-asserted codes. ATC is emitted first (primary),
- * RxNorm second (secondary); both are omitted when NULL, collapsing to
- * exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code.
- * When `germanAtc` is set, the same ATC leaf code is also published under
- * the BfArM URI AFTER the WHO entry — additive, never reordering WHO.
- */
-function medicationConcept(
- name: string,
- atcCode: string | null | undefined,
- rxNormCode: string | null | undefined,
- germanAtc: boolean,
-): FhirCodeableConcept {
- const coding: NonNullable = [];
- if (atcCode) {
- coding.push({ system: ATC_SYSTEM, code: atcCode, display: name });
- if (germanAtc) {
- coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name });
- }
- }
- if (rxNormCode) {
- coding.push({ system: RXNORM_SYSTEM, code: rxNormCode });
- }
- return coding.length > 0 ? { coding, text: name } : { text: name };
-}
-
-/**
- * v1.9.0 — UCUM codes for the dose units HealthLog stores. The display
- * `unit` is always the user's original string; the UCUM `code` is set
- * only for an unambiguous mapping so a consumer that resolves UCUM never
- * sees a guessed code. An unmapped unit drops `code` (and `system`) and
- * keeps just the human-readable `unit` — conformant, just not coded.
- */
-const UCUM_DOSE_CODES: Record = {
- mg: "mg",
- g: "g",
- mcg: "ug",
- µg: "ug",
- ug: "ug",
- ml: "mL",
- mL: "mL",
-};
-
-function doseQuantity(
- value: number,
- unit: string,
-): {
- value: number;
- unit: string;
- system?: string;
- code?: string;
-} {
- const ucum = UCUM_DOSE_CODES[unit];
- return ucum
- ? { value, unit, system: UCUM_SYSTEM, code: ucum }
- : { value, unit };
-}
-
-/**
- * Route of administration derived from the medication's delivery form,
- * carrying an additive SNOMED CT `coding` alongside the existing `.text`
- * anchor. HealthLog injections are subcutaneous (the injection-site picker
- * exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to
- * the subcutaneous route. Returns `undefined` for an unknown / absent form
- * so no empty route is emitted.
- */
-const ROUTE_SNOMED: Record = {
- ORAL: { code: "26643006", display: "Oral route" },
- INJECTION: { code: "34206005", display: "Subcutaneous route" },
-};
-
-function routeConcept(
- deliveryForm: string | null,
-): FhirCodeableConcept | undefined {
- const text =
- deliveryForm === "ORAL"
- ? "Oral"
- : deliveryForm === "INJECTION"
- ? "Injection"
- : undefined;
- if (!text) return undefined;
- const snomed = ROUTE_SNOMED[deliveryForm as string];
- return snomed
- ? {
- coding: [
- { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
- ],
- text,
- }
- : { text };
-}
-
-/**
- * Administration body-site keyed on the raw `InjectionSite` enum value,
- * carrying an additive SNOMED CT body-region `coding` alongside the `.text`
- * anchor. The map collapses the eight enum members to three gross body-region
- * concepts (abdomen / thigh / upper arm); laterality (left/right) and the
- * abdominal quadrant are NOT lateralised SNOMED concepts here — they are
- * preserved verbatim in the human-readable `.text` (the raw enum value), so
- * no information is lost.
- */
-const SITE_SNOMED: Record = {
- ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" },
- ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" },
- ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" },
- ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" },
- THIGH_LEFT: { code: "68367000", display: "Thigh structure" },
- THIGH_RIGHT: { code: "68367000", display: "Thigh structure" },
- UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" },
- UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" },
-};
-
-function siteConcept(injectionSite: string): FhirCodeableConcept {
- const snomed = SITE_SNOMED[injectionSite];
- // Preserve the full enum value (incl. laterality) as the readable anchor.
- const text = injectionSite;
- return snomed
- ? {
- coding: [
- { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
- ],
- text,
- }
- : { text };
-}
-
-function codeableFromMapping(m: LoincMapping): FhirCodeableConcept {
- if (m.loinc) {
- // HealthKit placeholder codes have no published LOINC term; they must not
- // sit under the LOINC namespace (a non-LOINC code there is a conformance
- // violation). Route them onto the shared custom CodeSystem instead —
- // byte-aligned with the iOS exporter.
- const system = m.loinc.startsWith("HKQuantityTypeIdentifier")
- ? HEALTHKIT_CODESYSTEM
- : LOINC_SYSTEM;
- return {
- coding: [{ system, code: m.loinc, display: m.display }],
- text: m.display,
- };
- }
- // No stable LOINC — local text-only concept (documented fallback).
- return { text: m.display };
-}
-
-/** v1.10.0 — English display per persisted wellness-score type (FHIR
- * text-only concept; the score has no published LOINC term). */
-const WELLNESS_SCORE_DISPLAY: Record = {
- RECOVERY_SCORE: "Recovery score",
- STRESS_SCORE: "Stress score",
- STRAIN_SCORE: "Strain score",
-};
-
-function categoryConcept(category: string): FhirCodeableConcept {
- return {
- coding: [
- {
- system: "http://terminology.hl7.org/CodeSystem/observation-category",
- code: category,
- },
- ],
- };
-}
-
-/** Latest `{ value, measuredAt }` for a type, or null when no rows. */
-function latestReading(
- data: DoctorReportData,
- type: string,
-): { value: number; measuredAt: string } | null {
- const series = data.measurements[type];
- if (!series || series.length === 0) return null;
- return series[series.length - 1];
-}
-
-/** Local-`#`-ref / patient anchor id shared by every subject reference. */
-export const PATIENT_RESOURCE_ID = "patient-1";
-const patientRef: FhirReference = {
- reference: `Patient/${PATIENT_RESOURCE_ID}`,
-};
-
-/**
- * Emit the `Patient` resource from the aggregated report data + decrypted
- * identity. Carries display name, gender, birth date and the KVNR identifier
- * when present; every absent field collapses its slot.
- */
-export function patientResource(
- data: DoctorReportData,
- identity: FhirPatientIdentity,
-): FhirPatient {
- const patient: FhirPatient = {
- resourceType: "Patient",
- id: PATIENT_RESOURCE_ID,
- };
- const displayName = data.patient.fullName ?? data.patient.username ?? null;
- if (displayName) patient.name = [{ text: displayName }];
- if (data.patient.gender) {
- patient.gender = data.patient.gender === "MALE" ? "male" : "female";
- }
- if (data.patient.dateOfBirth) {
- patient.birthDate = data.patient.dateOfBirth.slice(0, 10);
- }
- if (identity.insuranceNumber) {
- patient.identifier = [
- { system: KVNR_SYSTEM, value: identity.insuranceNumber },
- ];
- }
- return patient;
-}
-
-/**
- * Emit the `Coverage` resource, or `null` when there is no payor signal at
- * all. v1.9.0 — emitted whenever ANY payor signal is present: an insurer
- * name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server with
- * the iOS exporter (which emits a Coverage on a bare member id); it carries
- * the `subscriberId` with no contained payor Organization. When an insurer
- * name and/or IKNR is known, the payor is a CONTAINED Organization referenced
- * by a local `#`-ref. The KVNR stays on `Patient.identifier` and doubles as
- * the `subscriberId` (member id).
- */
-export function coverageResource(
- data: DoctorReportData,
- identity: FhirPatientIdentity,
-): FhirCoverage | null {
- const insurerName = data.patient.insurerName ?? null;
- const insurerIkNumber = data.patient.insurerIkNumber ?? null;
- const hasPayorOrg = Boolean(insurerName || insurerIkNumber);
- if (!hasPayorOrg && !identity.insuranceNumber) return null;
-
- const coverage: FhirCoverage = {
- resourceType: "Coverage",
- id: "coverage-1",
- status: "active",
- beneficiary: patientRef,
- };
- if (hasPayorOrg) {
- const orgId = "insurer-org-1";
- const payorOrg: FhirOrganization = {
- resourceType: "Organization",
- id: orgId,
- };
- if (insurerIkNumber) {
- payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }];
- }
- if (insurerName) payorOrg.name = insurerName;
- coverage.contained = [payorOrg];
- coverage.payor = [{ reference: `#${orgId}` }];
- }
- if (identity.insuranceNumber) {
- coverage.subscriberId = identity.insuranceNumber;
- }
- return coverage;
-}
-
-/**
- * Emit every `Observation` from the aggregated report data, in the canonical
- * order: one latest reading per measurement type, the blood-pressure panel,
- * the computed BMI, glucose per context, medication adherence, the opt-in
- * mood average, then the descriptive wellness composites under `survey`.
- *
- * The `obs-N` ids run as one continuous sequence across all of these so the
- * document builder's references stay stable; a `searchset` caller can filter
- * the returned array by `category`/`code` without re-numbering.
- */
-export function observationsFromReportData(
- data: DoctorReportData,
- _identity: FhirPatientIdentity,
- options: FhirBuildOptions = {},
-): FhirObservation[] {
- void options;
- const observations: FhirObservation[] = [];
- let obsSeq = 0;
- const push = (obs: FhirObservation) => observations.push(obs);
-
- // --- Vital-sign + activity Observations (one latest reading per type) --
- const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
-
- for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) {
- // BMI is emitted once by the computed-BMI block below (matching the PDF's
- // BMI line); skip the stored BODY_MASS_INDEX series here to avoid a
- // duplicate Observation.
- if (type === "BODY_MASS_INDEX") continue;
- const reading = latestReading(data, type);
- if (!reading) continue;
- // SLEEP_DURATION is stored in MINUTES; the iOS-locked UCUM unit is `h`, so
- // emit the value in hours to keep value and unit consistent. The PDF reads
- // the raw series independently and is unaffected.
- const value =
- type === "SLEEP_DURATION"
- ? Math.round((reading.value / 60) * 100) / 100
- : reading.value;
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept(mapping.category)],
- code: codeableFromMapping(mapping),
- subject: patientRef,
- effectiveDateTime: reading.measuredAt,
- valueQuantity: {
- value,
- unit: mapping.unit,
- system: UCUM_SYSTEM,
- code: mapping.unit,
- },
- });
- }
-
- // --- Blood-pressure panel (sys + dia components) -----------------------
- const sys = latestReading(data, "BLOOD_PRESSURE_SYS");
- const dia = latestReading(data, "BLOOD_PRESSURE_DIA");
- if (sys && dia) {
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("vital-signs")],
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: BP_PANEL_LOINC,
- display: "Blood pressure panel",
- },
- ],
- text: "Blood pressure",
- },
- subject: patientRef,
- // Use the systolic reading's timestamp as the panel effective time.
- effectiveDateTime: sys.measuredAt,
- component: [
- {
- code: {
- coding: [
- { system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" },
- ],
- },
- valueQuantity: {
- value: sys.value,
- unit: BP_UNIT,
- system: UCUM_SYSTEM,
- code: BP_UNIT,
- },
- },
- {
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: BP_DIA_LOINC,
- display: "Diastolic",
- },
- ],
- },
- valueQuantity: {
- value: dia.value,
- unit: BP_UNIT,
- system: UCUM_SYSTEM,
- code: BP_UNIT,
- },
- },
- ],
- });
- }
-
- // --- Computed BMI Observation (matches the PDF's BMI line) -------------
- if (data.bmi !== null && data.bmi !== undefined) {
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("vital-signs")],
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: "39156-5",
- display: "Body mass index (BMI) [Ratio]",
- },
- ],
- text: "Body mass index (BMI) [Ratio]",
- },
- subject: patientRef,
- effectiveDateTime: data.period.end,
- valueQuantity: {
- value: data.bmi,
- unit: "kg/m2",
- system: UCUM_SYSTEM,
- code: "kg/m2",
- },
- });
- }
-
- // --- Glucose Observations (per context, user display unit) -------------
- for (const [ctx, stat] of Object.entries(data.glucoseStats)) {
- const map = GLUCOSE_LOINC[ctx];
- if (!map) continue;
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("laboratory")],
- code: {
- coding: [
- { system: LOINC_SYSTEM, code: map.loinc, display: map.display },
- ],
- text: map.display,
- },
- subject: patientRef,
- effectiveDateTime: data.period.end,
- valueQuantity: {
- value: convertGlucose(stat.latest, glucoseUnit),
- unit: glucoseUnit,
- system: UCUM_SYSTEM,
- code: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL",
- },
- });
- }
-
- // --- Clinical glucose-panel Observations (v1.18.0) ---------------------
- // The server-computed TIR / GMI / estimated-A1C / mean / CV% over the report
- // period, by the one literature-locked engine the insights panel + coach +
- // PDF consume. Emitted only when readings exist (the aggregator zeroes the
- // panel when the glucose module is off, so an absent panel = module off OR no
- // data). Each carries the published LOINC where one exists; CV% has none and
- // rides a `survey` text-only concept. Percent metrics use UCUM `%`.
- const clinical = data.glucoseClinical;
- if (clinical && clinical.readingCount > 0) {
- const pushClinical = (opts: {
- loinc?: string;
- text: string;
- value: number;
- unit: string;
- ucum: string;
- survey?: boolean;
- }) => {
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept(opts.survey ? "survey" : "laboratory")],
- code: opts.loinc
- ? {
- coding: [
- { system: LOINC_SYSTEM, code: opts.loinc, display: opts.text },
- ],
- text: opts.text,
- }
- : { text: opts.text },
- subject: patientRef,
- effectiveDateTime: data.period.end,
- valueQuantity: {
- value: opts.value,
- unit: opts.unit,
- system: UCUM_SYSTEM,
- code: opts.ucum,
- },
- ...(clinical.isSpotEstimate
- ? {
- note: [
- {
- text: "Spot-reading estimate from individual measurements, not a continuous-monitor profile.",
- },
- ],
- }
- : {}),
- });
- };
-
- if (clinical.distribution) {
- pushClinical({
- loinc: GLUCOSE_TIR_LOINC,
- text: "Glucose time in range (70–180 mg/dL)",
- value: Math.round(clinical.distribution.tir * 1000) / 10,
- unit: "%",
- ucum: "%",
- });
- }
- if (clinical.meanMgdl !== null) {
- pushClinical({
- loinc: GLUCOSE_MEAN_LOINC,
- text: "Mean glucose",
- value: convertGlucose(clinical.meanMgdl, glucoseUnit),
- unit: glucoseUnit,
- ucum: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL",
- });
- }
- if (clinical.gmi !== null) {
- pushClinical({
- loinc: GLUCOSE_GMI_LOINC,
- text: "Glucose Management Indicator (GMI)",
- value: Math.round(clinical.gmi * 10) / 10,
- unit: "%",
- ucum: "%",
- });
- }
- if (clinical.estimatedA1c !== null) {
- pushClinical({
- loinc: GLUCOSE_EA1C_LOINC,
- text: "Estimated A1C",
- value: Math.round(clinical.estimatedA1c * 10) / 10,
- unit: "%",
- ucum: "%",
- });
- }
- if (clinical.variability !== null) {
- pushClinical({
- text: "Glucose variability (coefficient of variation)",
- value: Math.round(clinical.variability.cv * 10) / 10,
- unit: "%",
- ucum: "%",
- survey: true,
- });
- }
- }
-
- // --- Lab-result Observations (v1.17.1; LOINC + UCUM v1.18.8) -----------
- // One `laboratory` Observation per recorded analyte. `analyte` + `unit` are
- // user-recorded free text (no closed LOINC enum). v1.18.8 — a curated
- // analyte→LOINC map (`resolveLabCoding`) adds a real LOINC `coding` ALONGSIDE
- // the user's `code.text` for the common biomarkers, and stamps the canonical
- // UCUM `code` on the value WHEN the recorded unit matches the mapped
- // canonical symbol. An analyte that does NOT resolve keeps the honest
- // text-only `code` + display-only `unit` (no fabricated terminology). The
- // lab's reference bounds map onto R4 `referenceRange`.
- for (const lab of data.labResults ?? []) {
- obsSeq += 1;
- // Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO
- // carries a resolved canonical analyte name, thread it as the third arg so
- // the catalog name wins over the free-text `analyte`. Absent today.
- const coding = resolveLabCoding(lab.analyte, lab.unit);
- const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
- const code = coding
- ? {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: coding.loinc,
- display: coding.display,
- },
- ],
- text,
- }
- : { text };
-
- // v1.18.9 — a qualitative result (`value === null`) emits a
- // `valueCodeableConcept` (SNOMED qualifier value when confidently known,
- // else text-only) and NO `valueQuantity` / `referenceRange` — bounds are
- // meaningless for it. A numeric result keeps the established
- // valueQuantity + LOINC/UCUM path unchanged.
- if (lab.value === null) {
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("laboratory")],
- code,
- subject: patientRef,
- effectiveDateTime: lab.takenAt,
- valueCodeableConcept: qualitativeValueConcept(lab.valueText ?? ""),
- });
- continue;
- }
-
- const referenceRange =
- lab.referenceLow !== null || lab.referenceHigh !== null
- ? [
- {
- ...(lab.referenceLow !== null
- ? { low: { value: lab.referenceLow, unit: lab.unit } }
- : {}),
- ...(lab.referenceHigh !== null
- ? { high: { value: lab.referenceHigh, unit: lab.unit } }
- : {}),
- },
- ]
- : undefined;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("laboratory")],
- code,
- subject: patientRef,
- effectiveDateTime: lab.takenAt,
- valueQuantity: {
- value: lab.value,
- unit: lab.unit,
- system: UCUM_SYSTEM,
- // Canonical UCUM `code` only when the recorded unit matches; otherwise
- // the display `unit` stands alone (no coerced symbol).
- ...(coding?.ucum ? { code: coding.ucum } : {}),
- },
- ...(referenceRange ? { referenceRange } : {}),
- });
- }
-
- // --- Medication-adherence Observations (one per medication) ------------
- for (const [name, comp] of Object.entries(data.compliance)) {
- if (comp.total <= 0) continue;
- obsSeq += 1;
- // Integer percent — the one canonical rounding the app card + PDF use, so
- // a clinician sees the same adherence figure on every surface.
- const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("activity")],
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: MEDICATION_ADHERENCE_LOINC,
- display: "Medication adherence",
- },
- ],
- text: `Medication adherence — ${name}`,
- },
- subject: patientRef,
- effectiveDateTime: data.period.end,
- valueQuantity: { value: rate, unit: "%", system: UCUM_SYSTEM, code: "%" },
- });
- }
-
- // --- Mood Observation (opt-in only; absent when toggle off) ------------
- if (data.mood) {
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("vital-signs")],
- code: {
- coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }],
- text: "Mood (average over period)",
- },
- subject: patientRef,
- effectiveDateTime: data.period.end,
- valueQuantity: {
- value: Math.round(data.mood.avg * 10) / 10,
- unit: "{score}",
- system: UCUM_SYSTEM,
- code: "{score}",
- },
- });
- }
-
- // --- Wellness-score Observations (descriptive composites) --------------
- // v1.10.0 — the server-derived nightly scores (recovery / stress /
- // strain). They have no published LOINC term and are NOT clinical
- // findings, so each is emitted under the `survey` category with a
- // text-only concept and an explicit "descriptive, not a clinical
- // assessment" note — a physician's FHIR viewer never mistakes a band for
- // a diagnosis. Absent when the aggregator emitted no scores.
- if (data.wellnessScores && data.wellnessScores.length > 0) {
- for (const s of data.wellnessScores) {
- obsSeq += 1;
- push({
- resourceType: "Observation",
- id: `obs-${obsSeq}`,
- status: "final",
- category: [categoryConcept("survey")],
- code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" },
- subject: patientRef,
- effectiveDateTime: s.latestAt,
- valueQuantity: {
- value: s.latest,
- unit: "{score}",
- system: UCUM_SYSTEM,
- code: "{score}",
- },
- note: [
- {
- text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.",
- },
- ],
- });
- }
- }
-
- return observations;
-}
-
-/**
- * Phase → SNOMED-free display string for the current-phase Observation.
- * The four cycle phases have no single published LOINC answer-list term we
- * commit to, so the phase rides a text-only concept under the `survey`
- * category (a clinician's viewer reads it as a descriptive finding, not a
- * coded diagnosis), mirroring the wellness-score stance.
- */
-const CYCLE_PHASE_DISPLAY: Record = {
- MENSTRUAL: "Menstrual phase",
- FOLLICULAR: "Follicular phase",
- OVULATORY: "Ovulatory phase",
- LUTEAL: "Luteal phase",
-};
-
-/**
- * v1.15.0 — emit the cycle / reproductive-health Observations from the
- * opt-in cycle summary: LMP (LOINC 8665-2, a date value), average cycle
- * length (64700-8, days), average period length (64698-4, days), and the
- * current phase (text-only survey finding). Absent when the aggregator
- * carried no cycle summary (toggle off or no observed cycle). Ids run
- * `obs-cycle-N` so they never collide with the main `obs-N` sequence.
- */
-export function cycleObservationsFromReportData(
- data: DoctorReportData,
-): FhirObservation[] {
- const cycle = data.cycle;
- if (!cycle) return [];
- const observations: FhirObservation[] = [];
- let seq = 0;
- const next = () => `obs-cycle-${(seq += 1)}`;
- const effective = data.period.end;
-
- if (cycle.lastPeriodStart) {
- observations.push({
- resourceType: "Observation",
- id: next(),
- status: "final",
- category: [categoryConcept("survey")],
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: LMP_LOINC,
- display: "Last menstrual period start date",
- },
- ],
- text: "Last menstrual period (LMP)",
- },
- subject: patientRef,
- effectiveDateTime: effective,
- // LMP is a date value; emit the YYYY-MM-DD as a FHIR `date`.
- valueDateTime: cycle.lastPeriodStart,
- });
- }
-
- if (cycle.averageCycleLengthDays !== null) {
- observations.push({
- resourceType: "Observation",
- id: next(),
- status: "final",
- category: [categoryConcept("survey")],
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: CYCLE_LENGTH_LOINC,
- display: "Menstrual cycle length",
- },
- ],
- text: "Average menstrual cycle length",
- },
- subject: patientRef,
- effectiveDateTime: effective,
- valueQuantity: {
- value: cycle.averageCycleLengthDays,
- unit: "d",
- system: UCUM_SYSTEM,
- code: "d",
- },
- ...(cycle.cycleLengthVariabilityDays !== null
- ? {
- note: [
- {
- text: `Cycle-length variability (median absolute deviation): ±${cycle.cycleLengthVariabilityDays} days over ${cycle.observedCycleCount} observed cycle(s).`,
- },
- ],
- }
- : {}),
- });
- }
-
- if (cycle.averagePeriodLengthDays !== null) {
- observations.push({
- resourceType: "Observation",
- id: next(),
- status: "final",
- category: [categoryConcept("survey")],
- code: {
- coding: [
- {
- system: LOINC_SYSTEM,
- code: PERIOD_LENGTH_LOINC,
- display: "Length of menses",
- },
- ],
- text: "Average period length",
- },
- subject: patientRef,
- effectiveDateTime: effective,
- valueQuantity: {
- value: cycle.averagePeriodLengthDays,
- unit: "d",
- system: UCUM_SYSTEM,
- code: "d",
- },
- });
- }
-
- if (cycle.currentPhase) {
- observations.push({
- resourceType: "Observation",
- id: next(),
- status: "final",
- category: [categoryConcept("survey")],
- code: { text: "Current menstrual cycle phase" },
- subject: patientRef,
- effectiveDateTime: effective,
- valueString:
- CYCLE_PHASE_DISPLAY[cycle.currentPhase] ?? cycle.currentPhase,
- note: [
- {
- text: "Phase derived from logged cycle boundaries; descriptive, not a clinical assessment.",
- },
- ],
- });
- }
-
- return observations;
-}
-
-/**
- * SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept.
- * HealthLog records a patient-kept condition journal; it is NOT a diagnosing
- * device and asserts no specific ICD/SNOMED diagnosis. The user's own label
- * rides `code.text`. v1.18.8 — the coded `code` now carries the BROAD SNOMED
- * CT category concept for the episode's `IllnessType` (see
- * `ILLNESS_TYPE_SNOMED`); an unknown / future type falls back to this generic
- * root so a downstream system never reads a fabricated SPECIFIC diagnosis off
- * the export. The `verificationStatus: unconfirmed` + "patient-reported"
- * guard rails stay on every Condition.
- */
-const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
-
-/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
-function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
- return {
- coding: [
- {
- system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
- code: resolved ? "resolved" : "active",
- },
- ],
- };
-}
-
-/**
- * v1.18.1 P4 — emit one `Condition` per illness/condition episode plus a
- * bounding `Encounter`. Absent unless the illness module is enabled AND the
- * window held an episode (the aggregator only populates `illnessEpisodes`
- * then). Ids run `condition-1..N` / `encounter-1..N`; the Encounter references
- * its Condition so a clinician sees the time-boxed episode. No specific
- * diagnosis is coded — see `DISEASE_SNOMED`.
- */
-export function conditionsFromReportData(data: DoctorReportData): {
- conditions: FhirCondition[];
- encounters: FhirEncounter[];
-} {
- const episodes = data.illnessEpisodes;
- if (!episodes || episodes.length === 0) {
- return { conditions: [], encounters: [] };
- }
- const conditions: FhirCondition[] = [];
- const encounters: FhirEncounter[] = [];
- let seq = 0;
- for (const ep of episodes) {
- seq += 1;
- const conditionId = `condition-${seq}`;
- const resolved = ep.resolvedAt !== null;
- conditions.push({
- resourceType: "Condition",
- id: conditionId,
- clinicalStatus: conditionClinicalStatus(resolved),
- verificationStatus: {
- coding: [
- {
- system:
- "http://terminology.hl7.org/CodeSystem/condition-ver-status",
- // Patient-reported journal entry → "unconfirmed", never a
- // clinician-confirmed diagnosis.
- code: "unconfirmed",
- },
- ],
- },
- category: [
- {
- coding: [
- {
- system:
- "http://terminology.hl7.org/CodeSystem/condition-category",
- code: "problem-list-item",
- },
- ],
- // The journal's broad class as a human-readable category label.
- text: ep.type,
- },
- ],
- code: {
- // Broad SNOMED CT category for the journal type; the generic "Disease"
- // root is the honest fallback for an unknown / future type. NEVER a
- // specific diagnosis — the user's own label stays on `.text`.
- coding: [
- {
- system: SNOMED_SYSTEM,
- code: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).code,
- display: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).display,
- },
- ],
- text: ep.label,
- },
- subject: patientRef,
- onsetDateTime: ep.onsetAt,
- ...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
- note: [
- {
- text: `Self-recorded ${ep.lifecycle.toLowerCase().replace(/_/g, " ")} condition journal entry; patient-reported, not a clinical diagnosis.`,
- },
- ],
- });
- encounters.push({
- resourceType: "Encounter",
- id: `encounter-${seq}`,
- status: resolved ? "finished" : "in-progress",
- class: {
- system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
- code: "AMB",
- display: "ambulatory",
- },
- subject: patientRef,
- period: {
- start: ep.onsetAt,
- ...(ep.resolvedAt ? { end: ep.resolvedAt } : {}),
- },
- reasonReference: [{ reference: `Condition/${conditionId}` }],
- });
- }
- return { conditions, encounters };
-}
-
-/**
- * Emit one `MedicationStatement` per active medication. v1.9.0 — additive
- * ATC (primary) / RxNorm (secondary) codings when the user stored them;
- * falls back to the text-only concept otherwise. Ids run `med-1..N`.
- */
-export function medicationStatementsFromReportData(
- data: DoctorReportData,
- options: FhirBuildOptions = {},
-): FhirMedicationStatement[] {
- const germanAtc = options.germanAtc ?? false;
- const statements: FhirMedicationStatement[] = [];
- let medSeq = 0;
- for (const med of data.medications) {
- medSeq += 1;
- const id = `med-${medSeq}`;
- const stmt: FhirMedicationStatement = {
- resourceType: "MedicationStatement",
- id,
- status: "active",
- medicationCodeableConcept: medicationConcept(
- med.name,
- med.atcCode,
- med.rxNormCode,
- germanAtc,
- ),
- subject: patientRef,
- };
- if (med.dose) stmt.dosage = [{ text: med.dose }];
- statements.push(stmt);
- }
- return statements;
-}
-
-/**
- * Emit one `MedicationAdministration` per acted intake: `completed` (taken)
- * or `not-done` (explicitly skipped). Pending / missed slots and soft-deleted
- * tombstones are excluded upstream by the aggregator. The concept reuses the
- * same ATC/RxNorm coding as the statement so each administration is
- * self-describing without resolving a reference (no `partOf` / `request`
- * coupling). A `dosage` is emitted ONLY when a structured `dose` Quantity is
- * available; a dosage with only `.text` would violate the R4 dose-or-rate
- * invariant. Ids run `medadmin-1..N`.
- */
-export function medicationAdministrationsFromReportData(
- data: DoctorReportData,
- options: FhirBuildOptions = {},
-): FhirMedicationAdministration[] {
- const germanAtc = options.germanAtc ?? false;
- const administrations: FhirMedicationAdministration[] = [];
- let adminSeq = 0;
- for (const admin of data.medicationAdministrations ?? []) {
- adminSeq += 1;
- const id = `medadmin-${adminSeq}`;
- const resource: FhirMedicationAdministration = {
- resourceType: "MedicationAdministration",
- id,
- status: admin.status,
- medicationCodeableConcept: medicationConcept(
- admin.medicationName,
- admin.atcCode,
- admin.rxNormCode,
- germanAtc,
- ),
- subject: patientRef,
- effectiveDateTime: admin.effectiveAt,
- };
-
- // Dosage: only when a structured dose Quantity exists. Carry the
- // free-text dose + route + site alongside it. Route and site each carry
- // an additive SNOMED coding plus the existing `.text` anchor.
- if (admin.dose) {
- const dosage: FhirDosage = {
- dose: doseQuantity(admin.dose.value, admin.dose.unit),
- };
- if (admin.doseText) dosage.text = admin.doseText;
- const route = routeConcept(admin.deliveryForm);
- if (route) dosage.route = route;
- if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite);
- resource.dosage = dosage;
- }
-
- administrations.push(resource);
- }
- return administrations;
-}
diff --git a/src/lib/fhir/resources/common.ts b/src/lib/fhir/resources/common.ts
new file mode 100644
index 000000000..97fa59619
--- /dev/null
+++ b/src/lib/fhir/resources/common.ts
@@ -0,0 +1,137 @@
+/**
+ * Shared anchors for the per-resource FHIR R4 emitters: the patient id every
+ * subject reference points at, the terminology-system URIs, the option /
+ * identity types threaded from the export route, and the small concept
+ * helpers each emitter reuses.
+ *
+ * Kept in its own module so `patient` / `coverage` / `observation` / `labs` /
+ * `glucose` / `medications` / `conditions` / `cycle` can depend on the anchors
+ * without depending on each other.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import {
+ LOINC_SYSTEM,
+ HEALTHKIT_CODESYSTEM,
+ type LoincMapping,
+} from "@/lib/fhir/loinc-map";
+import type { FhirCodeableConcept, FhirReference } from "@/lib/fhir/types";
+
+/** Patient identity not carried in `DoctorReportData` (KVNR is encrypted). */
+export interface FhirPatientIdentity {
+ /** German KVNR (decrypted by the route). */
+ insuranceNumber: string | null;
+}
+
+/** KVNR identifier namespace per the gematik SID. */
+export const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10";
+
+/** German insurer institution-number (IKNR) identifier namespace. */
+export const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
+
+/**
+ * v1.9.0 — drug-coding system URIs. ATC is the portable WHO default
+ * (the iOS export emits the identical URI); RxNorm is the secondary US
+ * coding. Both are additive `coding[]` entries on the same concept; the
+ * free-text `.text` (the user's medication name) stays the anchor.
+ */
+export const ATC_SYSTEM = "http://www.whocc.no/atc";
+/**
+ * German national ATC URI maintained by the BfArM. Same ATC classification
+ * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
+ * a replacement) when a German-region export is requested, so the WHO entry
+ * stays first and byte-identical for every consumer.
+ */
+export const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
+export const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
+/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
+export const SNOMED_SYSTEM = "http://snomed.info/sct";
+
+/**
+ * The app locales for which a health-record export defaults `germanAtc` on
+ * (the additive BfArM ATC coding). The export route derives the flag from
+ * the user's locale against this set; the capabilities endpoint surfaces it
+ * verbatim so a client can predict the coding without a round-trip. Keeping
+ * it here — beside the BfArM URI it gates — makes the two move together.
+ */
+export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const;
+
+/** Options threaded from the export route into the emitters. Additive, all defaulted. */
+export interface FhirBuildOptions {
+ /**
+ * When true, additionally emit the German BfArM ATC URI alongside the WHO
+ * entry on each medication concept. The WHO coding stays first and
+ * byte-identical; this only appends a second URI for the same leaf code.
+ * Defaults off; the route turns it on for a German-region export.
+ */
+ germanAtc?: boolean;
+}
+
+/** Local-`#`-ref / patient anchor id shared by every subject reference. */
+export const PATIENT_RESOURCE_ID = "patient-1";
+
+export const patientRef: FhirReference = {
+ reference: `Patient/${PATIENT_RESOURCE_ID}`,
+};
+
+export function categoryConcept(category: string): FhirCodeableConcept {
+ return {
+ coding: [
+ {
+ system: "http://terminology.hl7.org/CodeSystem/observation-category",
+ code: category,
+ },
+ ],
+ };
+}
+
+export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept {
+ if (m.loinc) {
+ // HealthKit placeholder codes have no published LOINC term; they must not
+ // sit under the LOINC namespace (a non-LOINC code there is a conformance
+ // violation). Route them onto the shared custom CodeSystem instead —
+ // byte-aligned with the iOS exporter.
+ const system = m.loinc.startsWith("HKQuantityTypeIdentifier")
+ ? HEALTHKIT_CODESYSTEM
+ : LOINC_SYSTEM;
+ return {
+ coding: [{ system, code: m.loinc, display: m.display }],
+ text: m.display,
+ };
+ }
+ // No stable LOINC — local text-only concept (documented fallback).
+ return { text: m.display };
+}
+
+/** Latest `{ value, measuredAt }` for a type, or null when no rows. */
+export function latestReading(
+ data: DoctorReportData,
+ type: string,
+): { value: number; measuredAt: string } | null {
+ const series = data.measurements[type];
+ if (!series || series.length === 0) return null;
+ return series[series.length - 1];
+}
+
+/**
+ * Mints the shared `obs-N` sequence. The vital / BP / BMI / glucose / lab /
+ * adherence / mood / wellness emitters all draw from ONE allocator so the ids
+ * run as a single continuous run regardless of which blocks contributed —
+ * splitting the emitters across modules must not renumber the output.
+ */
+export function observationIdSequence(): () => string {
+ let seq = 0;
+ return () => `obs-${(seq += 1)}`;
+}
+
+/**
+ * The reporting window an aggregate Observation describes. Every value derived
+ * OVER the period (a mean, a share, a rate, an average) carries this rather
+ * than an `effectiveDateTime` pinned to the window's last instant — that
+ * timestamp asserts a reading that was never taken.
+ */
+export function reportingPeriod(data: DoctorReportData): {
+ start: string;
+ end: string;
+} {
+ return { start: data.period.start, end: data.period.end };
+}
diff --git a/src/lib/fhir/resources/conditions.ts b/src/lib/fhir/resources/conditions.ts
new file mode 100644
index 000000000..1a4984855
--- /dev/null
+++ b/src/lib/fhir/resources/conditions.ts
@@ -0,0 +1,124 @@
+/**
+ * `Condition` + bounding `Encounter` per illness / condition episode.
+ *
+ * HealthLog records a patient-kept condition journal; it is NOT a diagnosing
+ * device and asserts no specific ICD/SNOMED diagnosis. The user's own label
+ * rides `code.text`; the coded `code` carries the BROAD SNOMED CT category for
+ * the episode's `IllnessType`, with the generic "Disease" root as the honest
+ * fallback for an unknown / future type.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
+import type {
+ FhirCodeableConcept,
+ FhirCondition,
+ FhirEncounter,
+} from "@/lib/fhir/types";
+import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common";
+
+/** SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. */
+const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
+
+/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
+function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
+ return {
+ coding: [
+ {
+ system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
+ code: resolved ? "resolved" : "active",
+ },
+ ],
+ };
+}
+
+/**
+ * v1.18.1 P4 — emit one `Condition` per illness/condition episode plus a
+ * bounding `Encounter`. Absent unless the illness module is enabled AND the
+ * window held an episode (the aggregator only populates `illnessEpisodes`
+ * then). Ids run `condition-1..N` / `encounter-1..N`; the Encounter references
+ * its Condition so a clinician sees the time-boxed episode.
+ */
+export function conditionsFromReportData(data: DoctorReportData): {
+ conditions: FhirCondition[];
+ encounters: FhirEncounter[];
+} {
+ const episodes = data.illnessEpisodes;
+ if (!episodes || episodes.length === 0) {
+ return { conditions: [], encounters: [] };
+ }
+ const conditions: FhirCondition[] = [];
+ const encounters: FhirEncounter[] = [];
+ let seq = 0;
+ for (const ep of episodes) {
+ seq += 1;
+ const conditionId = `condition-${seq}`;
+ const resolved = ep.resolvedAt !== null;
+ conditions.push({
+ resourceType: "Condition",
+ id: conditionId,
+ clinicalStatus: conditionClinicalStatus(resolved),
+ verificationStatus: {
+ coding: [
+ {
+ system:
+ "http://terminology.hl7.org/CodeSystem/condition-ver-status",
+ // Patient-reported journal entry → "unconfirmed", never a
+ // clinician-confirmed diagnosis.
+ code: "unconfirmed",
+ },
+ ],
+ },
+ category: [
+ {
+ coding: [
+ {
+ system:
+ "http://terminology.hl7.org/CodeSystem/condition-category",
+ code: "problem-list-item",
+ },
+ ],
+ // The journal's broad class as a human-readable category label.
+ text: ep.type,
+ },
+ ],
+ code: {
+ // Broad SNOMED CT category for the journal type; the generic "Disease"
+ // root is the honest fallback for an unknown / future type. NEVER a
+ // specific diagnosis — the user's own label stays on `.text`.
+ coding: [
+ {
+ system: SNOMED_SYSTEM,
+ code: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).code,
+ display: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).display,
+ },
+ ],
+ text: ep.label,
+ },
+ subject: patientRef,
+ onsetDateTime: ep.onsetAt,
+ ...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
+ note: [
+ {
+ text: `Self-recorded ${ep.lifecycle.toLowerCase().replace(/_/g, " ")} condition journal entry; patient-reported, not a clinical diagnosis.`,
+ },
+ ],
+ });
+ encounters.push({
+ resourceType: "Encounter",
+ id: `encounter-${seq}`,
+ status: resolved ? "finished" : "in-progress",
+ class: {
+ system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
+ code: "AMB",
+ display: "ambulatory",
+ },
+ subject: patientRef,
+ period: {
+ start: ep.onsetAt,
+ ...(ep.resolvedAt ? { end: ep.resolvedAt } : {}),
+ },
+ reasonReference: [{ reference: `Condition/${conditionId}` }],
+ });
+ }
+ return { conditions, encounters };
+}
diff --git a/src/lib/fhir/resources/coverage.ts b/src/lib/fhir/resources/coverage.ts
new file mode 100644
index 000000000..09edbfbd0
--- /dev/null
+++ b/src/lib/fhir/resources/coverage.ts
@@ -0,0 +1,52 @@
+/**
+ * The `Coverage` resource — the insurer relationship, with the payor as a
+ * CONTAINED `Organization` referenced by a local `#`-ref.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import type { FhirCoverage, FhirOrganization } from "@/lib/fhir/types";
+import {
+ IKNR_SYSTEM,
+ patientRef,
+ type FhirPatientIdentity,
+} from "@/lib/fhir/resources/common";
+
+/**
+ * Emit the `Coverage`, or `null` when no payor is known.
+ *
+ * R4 marks `Coverage.payor` as `1..*`: a Coverage names who pays, and one that
+ * cannot is not a Coverage. A record that carries only a member id (a bare
+ * KVNR, no insurer name and no IKNR) therefore emits none — nothing is lost,
+ * because the member id already rides `Patient.identifier` and doubles as the
+ * `subscriberId` when a real Coverage does go out.
+ */
+export function coverageResource(
+ data: DoctorReportData,
+ identity: FhirPatientIdentity,
+): FhirCoverage | null {
+ const insurerName = data.patient.insurerName ?? null;
+ const insurerIkNumber = data.patient.insurerIkNumber ?? null;
+ if (!insurerName && !insurerIkNumber) return null;
+
+ const orgId = "insurer-org-1";
+ const payorOrg: FhirOrganization = {
+ resourceType: "Organization",
+ id: orgId,
+ };
+ if (insurerIkNumber) {
+ payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }];
+ }
+ if (insurerName) payorOrg.name = insurerName;
+
+ const coverage: FhirCoverage = {
+ resourceType: "Coverage",
+ id: "coverage-1",
+ status: "active",
+ beneficiary: patientRef,
+ contained: [payorOrg],
+ payor: [{ reference: `#${orgId}` }],
+ };
+ if (identity.insuranceNumber) {
+ coverage.subscriberId = identity.insuranceNumber;
+ }
+ return coverage;
+}
diff --git a/src/lib/fhir/resources/cycle.ts b/src/lib/fhir/resources/cycle.ts
new file mode 100644
index 000000000..a954db26d
--- /dev/null
+++ b/src/lib/fhir/resources/cycle.ts
@@ -0,0 +1,146 @@
+/**
+ * v1.15.0 — the cycle / reproductive-health Observations from the opt-in cycle
+ * summary: LMP (LOINC 8665-2, a date value), average cycle length (64700-8,
+ * days), average period length (64698-4, days), and the current phase
+ * (text-only survey finding). Absent when the aggregator carried no cycle
+ * summary (toggle off or no observed cycle). Ids run `obs-cycle-N` so they
+ * never collide with the main `obs-N` sequence.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import {
+ LOINC_SYSTEM,
+ LMP_LOINC,
+ CYCLE_LENGTH_LOINC,
+ PERIOD_LENGTH_LOINC,
+} from "@/lib/fhir/loinc-map";
+import { ucumQuantity } from "@/lib/fhir/ucum";
+import type { FhirObservation } from "@/lib/fhir/types";
+import {
+ categoryConcept,
+ patientRef,
+ reportingPeriod,
+} from "@/lib/fhir/resources/common";
+
+/**
+ * Phase → display string for the current-phase Observation. The four cycle
+ * phases have no single published LOINC answer-list term we commit to, so the
+ * phase rides a text-only concept under the `survey` category (a clinician's
+ * viewer reads it as a descriptive finding, not a coded diagnosis), mirroring
+ * the wellness-score stance.
+ */
+const CYCLE_PHASE_DISPLAY: Record = {
+ MENSTRUAL: "Menstrual phase",
+ FOLLICULAR: "Follicular phase",
+ OVULATORY: "Ovulatory phase",
+ LUTEAL: "Luteal phase",
+};
+
+export function cycleObservationsFromReportData(
+ data: DoctorReportData,
+): FhirObservation[] {
+ const cycle = data.cycle;
+ if (!cycle) return [];
+ const observations: FhirObservation[] = [];
+ let seq = 0;
+ const next = () => `obs-cycle-${(seq += 1)}`;
+ // Every cycle figure is derived across the whole window, not read at its
+ // last instant.
+ const effectivePeriod = reportingPeriod(data);
+
+ if (cycle.lastPeriodStart) {
+ observations.push({
+ resourceType: "Observation",
+ id: next(),
+ status: "final",
+ category: [categoryConcept("survey")],
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: LMP_LOINC,
+ display: "Last menstrual period start date",
+ },
+ ],
+ text: "Last menstrual period (LMP)",
+ },
+ subject: patientRef,
+ effectivePeriod,
+ // LMP is a date value; emit the YYYY-MM-DD as a FHIR `date`.
+ valueDateTime: cycle.lastPeriodStart,
+ });
+ }
+
+ if (cycle.averageCycleLengthDays !== null) {
+ observations.push({
+ resourceType: "Observation",
+ id: next(),
+ status: "final",
+ category: [categoryConcept("survey")],
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: CYCLE_LENGTH_LOINC,
+ display: "Menstrual cycle length",
+ },
+ ],
+ text: "Average menstrual cycle length",
+ },
+ subject: patientRef,
+ effectivePeriod,
+ valueQuantity: ucumQuantity(cycle.averageCycleLengthDays, "d"),
+ ...(cycle.cycleLengthVariabilityDays !== null
+ ? {
+ note: [
+ {
+ text: `Cycle-length variability (median absolute deviation): ±${cycle.cycleLengthVariabilityDays} days over ${cycle.observedCycleCount} observed cycle(s).`,
+ },
+ ],
+ }
+ : {}),
+ });
+ }
+
+ if (cycle.averagePeriodLengthDays !== null) {
+ observations.push({
+ resourceType: "Observation",
+ id: next(),
+ status: "final",
+ category: [categoryConcept("survey")],
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: PERIOD_LENGTH_LOINC,
+ display: "Length of menses",
+ },
+ ],
+ text: "Average period length",
+ },
+ subject: patientRef,
+ effectivePeriod,
+ valueQuantity: ucumQuantity(cycle.averagePeriodLengthDays, "d"),
+ });
+ }
+
+ if (cycle.currentPhase) {
+ observations.push({
+ resourceType: "Observation",
+ id: next(),
+ status: "final",
+ category: [categoryConcept("survey")],
+ code: { text: "Current menstrual cycle phase" },
+ subject: patientRef,
+ effectivePeriod,
+ valueString:
+ CYCLE_PHASE_DISPLAY[cycle.currentPhase] ?? cycle.currentPhase,
+ note: [
+ {
+ text: "Phase derived from logged cycle boundaries; descriptive, not a clinical assessment.",
+ },
+ ],
+ });
+ }
+
+ return observations;
+}
diff --git a/src/lib/fhir/resources/glucose.ts b/src/lib/fhir/resources/glucose.ts
new file mode 100644
index 000000000..6bb30e618
--- /dev/null
+++ b/src/lib/fhir/resources/glucose.ts
@@ -0,0 +1,171 @@
+/**
+ * Glucose Observations: the per-context reading and the clinical panel
+ * (time-in-range, mean, GMI, estimated A1C, variability) the one
+ * literature-locked engine computes for the report period.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import {
+ resolveGlucoseUnit,
+ convertGlucose,
+ type GlucoseUnit,
+} from "@/lib/glucose";
+import {
+ LOINC_SYSTEM,
+ GLUCOSE_LOINC,
+ GLUCOSE_TIR_LOINC,
+ GLUCOSE_GMI_LOINC,
+ GLUCOSE_MEAN_LOINC,
+ GLUCOSE_EA1C_LOINC,
+} from "@/lib/fhir/loinc-map";
+import { ucumQuantity } from "@/lib/fhir/ucum";
+import type { FhirObservation } from "@/lib/fhir/types";
+import {
+ categoryConcept,
+ patientRef,
+ reportingPeriod,
+} from "@/lib/fhir/resources/common";
+
+/**
+ * The time-in-range band, stated in the unit the reader uses. The band itself
+ * is fixed at 70–180 mg/dL [Battelino 2019]; only its presentation converts,
+ * exactly as the sibling mean-glucose value does. Printing "70–180 mg/dL" to a
+ * mmol/L reader hands them a number they cannot compare against their own.
+ */
+function timeInRangeDisplay(unit: GlucoseUnit): string {
+ const low = convertGlucose(70, unit);
+ const high = convertGlucose(180, unit);
+ return `Glucose time in range (${low}–${high} ${unit})`;
+}
+
+/**
+ * One Observation per recorded glucose context, valued in the user's display
+ * unit. The value is the context's LATEST reading but the code is a context
+ * summary, so it carries the reporting period rather than a reading instant.
+ */
+export function glucoseContextObservations(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
+ const observations: FhirObservation[] = [];
+ for (const [ctx, stat] of Object.entries(data.glucoseStats)) {
+ const map = GLUCOSE_LOINC[ctx];
+ if (!map) continue;
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("laboratory")],
+ code: {
+ coding: [
+ { system: LOINC_SYSTEM, code: map.loinc, display: map.display },
+ ],
+ text: map.display,
+ },
+ subject: patientRef,
+ effectivePeriod: reportingPeriod(data),
+ valueQuantity: ucumQuantity(
+ convertGlucose(stat.latest, glucoseUnit),
+ glucoseUnit,
+ ),
+ });
+ }
+ return observations;
+}
+
+/**
+ * The clinical glucose panel (v1.18.0). Emitted only when readings exist — the
+ * aggregator zeroes the panel when the glucose module is off, so an absent
+ * panel means the module is off OR there is no data. Each metric carries its
+ * published LOINC where one exists; the coefficient of variation has none and
+ * rides a `survey` text-only concept.
+ */
+export function glucoseClinicalObservations(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const clinical = data.glucoseClinical;
+ if (!clinical || clinical.readingCount <= 0) return [];
+
+ const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
+ const period = reportingPeriod(data);
+ const observations: FhirObservation[] = [];
+
+ const push = (opts: {
+ loinc?: string;
+ text: string;
+ value: number;
+ unit: string;
+ survey?: boolean;
+ }) => {
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept(opts.survey ? "survey" : "laboratory")],
+ code: opts.loinc
+ ? {
+ coding: [
+ { system: LOINC_SYSTEM, code: opts.loinc, display: opts.text },
+ ],
+ text: opts.text,
+ }
+ : { text: opts.text },
+ subject: patientRef,
+ effectivePeriod: period,
+ valueQuantity: ucumQuantity(opts.value, opts.unit),
+ ...(clinical.isSpotEstimate
+ ? {
+ note: [
+ {
+ text: "Spot-reading estimate from individual measurements, not a continuous-monitor profile.",
+ },
+ ],
+ }
+ : {}),
+ });
+ };
+
+ if (clinical.distribution) {
+ push({
+ loinc: GLUCOSE_TIR_LOINC,
+ text: timeInRangeDisplay(glucoseUnit),
+ value: Math.round(clinical.distribution.tir * 1000) / 10,
+ unit: "%",
+ });
+ }
+ if (clinical.meanMgdl !== null) {
+ push({
+ loinc: GLUCOSE_MEAN_LOINC,
+ text: "Mean glucose",
+ value: convertGlucose(clinical.meanMgdl, glucoseUnit),
+ unit: glucoseUnit,
+ });
+ }
+ if (clinical.gmi !== null) {
+ push({
+ loinc: GLUCOSE_GMI_LOINC,
+ text: "Glucose Management Indicator (GMI)",
+ value: Math.round(clinical.gmi * 10) / 10,
+ unit: "%",
+ });
+ }
+ if (clinical.estimatedA1c !== null) {
+ push({
+ loinc: GLUCOSE_EA1C_LOINC,
+ text: "Estimated A1C",
+ value: Math.round(clinical.estimatedA1c * 10) / 10,
+ unit: "%",
+ });
+ }
+ if (clinical.variability !== null) {
+ push({
+ text: "Glucose variability (coefficient of variation)",
+ value: Math.round(clinical.variability.cv * 10) / 10,
+ unit: "%",
+ survey: true,
+ });
+ }
+
+ return observations;
+}
diff --git a/src/lib/fhir/resources/index.ts b/src/lib/fhir/resources/index.ts
new file mode 100644
index 000000000..cc69c89e8
--- /dev/null
+++ b/src/lib/fhir/resources/index.ts
@@ -0,0 +1,38 @@
+/**
+ * v1.11.0 — shared per-resource FHIR R4 emitters.
+ *
+ * The Observation / MedicationStatement / MedicationAdministration / Patient /
+ * Coverage / Condition / Encounter / cycle builders (with their
+ * LOINC/ATC/SNOMED/UCUM codings and the `survey` wellness split) are small pure
+ * functions over the SAME `DoctorReportData` the document-bundle builder
+ * consumes. This barrel is their one public face: `buildFhirDocumentBundle`
+ * composes them into a `type: "document"` Bundle, and the FHIR REST routes wrap
+ * them in a `type: "searchset"` Bundle. The coding logic has exactly one home,
+ * so the document export and the REST face can never drift apart.
+ *
+ * No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
+ * (`../types`), matching the project's "hand-rolled over the documented wire"
+ * convention. All text is escaped plain text; never user-supplied HTML
+ * (no markdown library, no `dangerouslySetInnerHTML`).
+ */
+export {
+ ATC_SYSTEM,
+ SNOMED_SYSTEM,
+ GERMAN_ATC_DEFAULT_LOCALES,
+ PATIENT_RESOURCE_ID,
+ type FhirBuildOptions,
+ type FhirPatientIdentity,
+} from "@/lib/fhir/resources/common";
+
+export {
+ administrativeGender,
+ patientResource,
+} from "@/lib/fhir/resources/patient";
+export { coverageResource } from "@/lib/fhir/resources/coverage";
+export { observationsFromReportData } from "@/lib/fhir/resources/observation";
+export { cycleObservationsFromReportData } from "@/lib/fhir/resources/cycle";
+export { conditionsFromReportData } from "@/lib/fhir/resources/conditions";
+export {
+ medicationStatementsFromReportData,
+ medicationAdministrationsFromReportData,
+} from "@/lib/fhir/resources/medications";
diff --git a/src/lib/fhir/resources/labs.ts b/src/lib/fhir/resources/labs.ts
new file mode 100644
index 000000000..a02a82228
--- /dev/null
+++ b/src/lib/fhir/resources/labs.ts
@@ -0,0 +1,113 @@
+/**
+ * Lab-result Observations (v1.17.1; LOINC + UCUM v1.18.8).
+ *
+ * One `laboratory` Observation per recorded analyte. `analyte` + `unit` are
+ * user-recorded free text (no closed LOINC enum). A curated analyte→LOINC map
+ * (`resolveLabCoding`) adds a real LOINC `coding` ALONGSIDE the user's
+ * `code.text` for the common biomarkers, and stamps the canonical UCUM `code`
+ * on the value WHEN the recorded unit matches the mapped canonical symbol. An
+ * analyte that does NOT resolve keeps the honest text-only `code` +
+ * display-only `unit` (no fabricated terminology). The lab's reference bounds
+ * map onto R4 `referenceRange`.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
+import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
+import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative";
+
+import type {
+ FhirObservation,
+ FhirObservationReferenceRange,
+} from "@/lib/fhir/types";
+import { categoryConcept, patientRef } from "@/lib/fhir/resources/common";
+
+export function labObservations(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const observations: FhirObservation[] = [];
+
+ for (const lab of data.labResults ?? []) {
+ // Forward-compat: when the DTO carries a resolved canonical analyte name,
+ // thread it as the third arg so the catalog name wins over the free-text
+ // `analyte`. Absent today.
+ const coding = resolveLabCoding(lab.analyte, lab.unit);
+ const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
+ const code = coding
+ ? {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: coding.loinc,
+ display: coding.display,
+ },
+ ],
+ text,
+ }
+ : { text };
+
+ // v1.18.9 — a qualitative result (`value === null`) emits a
+ // `valueCodeableConcept` (SNOMED qualifier value when confidently known,
+ // else text-only) and NO `valueQuantity` / `referenceRange` — bounds are
+ // meaningless for it. A numeric result keeps the established
+ // valueQuantity + LOINC/UCUM path.
+ if (lab.value === null) {
+ const valueText = lab.valueText?.trim() ?? "";
+ // A row with neither a number nor a result term states nothing. An
+ // Observation whose only value is an empty concept is worse than no
+ // Observation: it asserts a result was recorded and then withholds it.
+ if (valueText === "") continue;
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("laboratory")],
+ code,
+ subject: patientRef,
+ effectiveDateTime: lab.takenAt,
+ valueCodeableConcept: qualitativeValueConcept(valueText),
+ });
+ continue;
+ }
+
+ // A lab quantity: the recorded `unit` is always the display string, and
+ // the canonical UCUM `code` is stamped only when the analyte map resolved
+ // one for exactly that unit (no coerced symbol).
+ const quantity = (value: number) => ({
+ value,
+ unit: lab.unit,
+ system: UCUM_SYSTEM,
+ ...(coding?.ucum ? { code: coding.ucum } : {}),
+ });
+ // The bounds are stated in the SAME unit as the value, so they carry the
+ // SAME coding — a bare unit beside a coded value leaves a receiver unable
+ // to compare the two.
+ const referenceRange: FhirObservationReferenceRange[] | undefined =
+ lab.referenceLow !== null || lab.referenceHigh !== null
+ ? [
+ {
+ ...(lab.referenceLow !== null
+ ? { low: quantity(lab.referenceLow) }
+ : {}),
+ ...(lab.referenceHigh !== null
+ ? { high: quantity(lab.referenceHigh) }
+ : {}),
+ },
+ ]
+ : undefined;
+
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("laboratory")],
+ code,
+ subject: patientRef,
+ effectiveDateTime: lab.takenAt,
+ valueQuantity: quantity(lab.value),
+ ...(referenceRange ? { referenceRange } : {}),
+ });
+ }
+
+ return observations;
+}
diff --git a/src/lib/fhir/resources/medications.ts b/src/lib/fhir/resources/medications.ts
new file mode 100644
index 000000000..233caee61
--- /dev/null
+++ b/src/lib/fhir/resources/medications.ts
@@ -0,0 +1,224 @@
+/**
+ * `MedicationStatement` (one per active medication) and
+ * `MedicationAdministration` (one per acted intake), with their additive
+ * ATC / RxNorm codings and the dose / route / site SNOMED anchors.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
+import type {
+ FhirCodeableConcept,
+ FhirDosage,
+ FhirMedicationAdministration,
+ FhirMedicationStatement,
+} from "@/lib/fhir/types";
+import {
+ ATC_BFARM_SYSTEM,
+ ATC_SYSTEM,
+ RXNORM_SYSTEM,
+ SNOMED_SYSTEM,
+ patientRef,
+ type FhirBuildOptions,
+} from "@/lib/fhir/resources/common";
+
+/**
+ * Build a `medicationCodeableConcept` from a medication's free-text name
+ * plus its optional user-asserted codes. ATC is emitted first (primary),
+ * RxNorm second (secondary); both are omitted when NULL, collapsing to
+ * exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code.
+ * When `germanAtc` is set, the same ATC leaf code is also published under
+ * the BfArM URI AFTER the WHO entry — additive, never reordering WHO.
+ */
+function medicationConcept(
+ name: string,
+ atcCode: string | null | undefined,
+ rxNormCode: string | null | undefined,
+ germanAtc: boolean,
+): FhirCodeableConcept {
+ const coding: NonNullable = [];
+ if (atcCode) {
+ coding.push({ system: ATC_SYSTEM, code: atcCode, display: name });
+ if (germanAtc) {
+ coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name });
+ }
+ }
+ if (rxNormCode) {
+ coding.push({ system: RXNORM_SYSTEM, code: rxNormCode });
+ }
+ return coding.length > 0 ? { coding, text: name } : { text: name };
+}
+
+/**
+ * v1.9.0 — the dose units HealthLog stores, normalised to their UCUM symbol.
+ * The display `unit` is always the user's original string; the UCUM `code` is
+ * set only for an unambiguous mapping so a consumer that resolves UCUM never
+ * sees a guessed code. An unmapped unit keeps just the human-readable `unit`.
+ */
+const UCUM_DOSE_CODES: Record = {
+ mg: "mg",
+ g: "g",
+ mcg: "ug",
+ µg: "ug",
+ ug: "ug",
+ ml: "mL",
+ mL: "mL",
+};
+
+function doseQuantity(
+ value: number,
+ unit: string,
+): { value: number; unit: string; system?: string; code?: string } {
+ const ucum = UCUM_DOSE_CODES[unit];
+ return ucum
+ ? { value, unit, system: UCUM_SYSTEM, code: ucum }
+ : { value, unit };
+}
+
+/**
+ * Route of administration derived from the medication's delivery form,
+ * carrying an additive SNOMED CT `coding` alongside the existing `.text`
+ * anchor. HealthLog injections are subcutaneous (the injection-site picker
+ * exists for the self-injection workflow), so `INJECTION` maps to the
+ * subcutaneous route. Returns `undefined` for an unknown / absent form so no
+ * empty route is emitted.
+ */
+const ROUTE_SNOMED: Record = {
+ ORAL: { code: "26643006", display: "Oral route" },
+ INJECTION: { code: "34206005", display: "Subcutaneous route" },
+};
+
+function routeConcept(
+ deliveryForm: string | null,
+): FhirCodeableConcept | undefined {
+ const text =
+ deliveryForm === "ORAL"
+ ? "Oral"
+ : deliveryForm === "INJECTION"
+ ? "Injection"
+ : undefined;
+ if (!text) return undefined;
+ const snomed = ROUTE_SNOMED[deliveryForm as string];
+ return snomed
+ ? {
+ coding: [
+ { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
+ ],
+ text,
+ }
+ : { text };
+}
+
+/**
+ * Administration body-site keyed on the raw `InjectionSite` enum value,
+ * carrying an additive SNOMED CT body-region `coding` alongside the `.text`
+ * anchor. The map collapses the eight enum members to three gross body-region
+ * concepts (abdomen / thigh / upper arm); laterality (left/right) and the
+ * abdominal quadrant are NOT lateralised SNOMED concepts here — they are
+ * preserved verbatim in the human-readable `.text` (the raw enum value), so
+ * no information is lost.
+ */
+const SITE_SNOMED: Record = {
+ ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" },
+ ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" },
+ ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" },
+ ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" },
+ THIGH_LEFT: { code: "68367000", display: "Thigh structure" },
+ THIGH_RIGHT: { code: "68367000", display: "Thigh structure" },
+ UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" },
+ UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" },
+};
+
+function siteConcept(injectionSite: string): FhirCodeableConcept {
+ const snomed = SITE_SNOMED[injectionSite];
+ // Preserve the full enum value (incl. laterality) as the readable anchor.
+ const text = injectionSite;
+ return snomed
+ ? {
+ coding: [
+ { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
+ ],
+ text,
+ }
+ : { text };
+}
+
+/**
+ * Emit one `MedicationStatement` per active medication. Ids run `med-1..N`.
+ */
+export function medicationStatementsFromReportData(
+ data: DoctorReportData,
+ options: FhirBuildOptions = {},
+): FhirMedicationStatement[] {
+ const germanAtc = options.germanAtc ?? false;
+ const statements: FhirMedicationStatement[] = [];
+ let medSeq = 0;
+ for (const med of data.medications) {
+ medSeq += 1;
+ const stmt: FhirMedicationStatement = {
+ resourceType: "MedicationStatement",
+ id: `med-${medSeq}`,
+ status: "active",
+ medicationCodeableConcept: medicationConcept(
+ med.name,
+ med.atcCode,
+ med.rxNormCode,
+ germanAtc,
+ ),
+ subject: patientRef,
+ };
+ if (med.dose) stmt.dosage = [{ text: med.dose }];
+ statements.push(stmt);
+ }
+ return statements;
+}
+
+/**
+ * Emit one `MedicationAdministration` per acted intake: `completed` (taken)
+ * or `not-done` (explicitly skipped). Pending / missed slots and soft-deleted
+ * tombstones are excluded upstream by the aggregator. The concept reuses the
+ * same ATC/RxNorm coding as the statement so each administration is
+ * self-describing without resolving a reference (no `partOf` / `request`
+ * coupling). A `dosage` is emitted ONLY when a structured `dose` Quantity is
+ * available; a dosage with only `.text` would violate the R4 dose-or-rate
+ * invariant. Ids run `medadmin-1..N`.
+ */
+export function medicationAdministrationsFromReportData(
+ data: DoctorReportData,
+ options: FhirBuildOptions = {},
+): FhirMedicationAdministration[] {
+ const germanAtc = options.germanAtc ?? false;
+ const administrations: FhirMedicationAdministration[] = [];
+ let adminSeq = 0;
+ for (const admin of data.medicationAdministrations ?? []) {
+ adminSeq += 1;
+ const resource: FhirMedicationAdministration = {
+ resourceType: "MedicationAdministration",
+ id: `medadmin-${adminSeq}`,
+ status: admin.status,
+ medicationCodeableConcept: medicationConcept(
+ admin.medicationName,
+ admin.atcCode,
+ admin.rxNormCode,
+ germanAtc,
+ ),
+ subject: patientRef,
+ effectiveDateTime: admin.effectiveAt,
+ };
+
+ // Dosage: only when a structured dose Quantity exists. Carry the
+ // free-text dose + route + site alongside it. Route and site each carry
+ // an additive SNOMED coding plus the existing `.text` anchor.
+ if (admin.dose) {
+ const dosage: FhirDosage = {
+ dose: doseQuantity(admin.dose.value, admin.dose.unit),
+ };
+ if (admin.doseText) dosage.text = admin.doseText;
+ const route = routeConcept(admin.deliveryForm);
+ if (route) dosage.route = route;
+ if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite);
+ resource.dosage = dosage;
+ }
+
+ administrations.push(resource);
+ }
+ return administrations;
+}
diff --git a/src/lib/fhir/resources/observation.ts b/src/lib/fhir/resources/observation.ts
new file mode 100644
index 000000000..07176a653
--- /dev/null
+++ b/src/lib/fhir/resources/observation.ts
@@ -0,0 +1,283 @@
+/**
+ * The Observation orchestrator: one array, one continuous `obs-N` sequence,
+ * in the canonical order — one latest reading per measurement type, the
+ * blood-pressure panel, the computed BMI, glucose per context, the clinical
+ * glucose panel, labs, medication adherence, the opt-in mood average, then the
+ * descriptive wellness composites under `survey`.
+ *
+ * The per-domain blocks (glucose, labs) live in their own modules and draw ids
+ * from the SAME allocator, so a document builder's references stay stable and a
+ * `searchset` caller can filter the array by `category` / `code` without
+ * re-numbering.
+ */
+import {
+ adherenceRatePercent,
+ type DoctorReportData,
+} from "@/lib/doctor-report-data";
+import {
+ LOINC_SYSTEM,
+ MEASUREMENT_LOINC,
+ BP_PANEL_LOINC,
+ BP_SYS_LOINC,
+ BP_DIA_LOINC,
+ BP_UNIT,
+ MEDICATION_ADHERENCE_LOINC,
+ MOOD_LOINC,
+} from "@/lib/fhir/loinc-map";
+import { ucumQuantity } from "@/lib/fhir/ucum";
+import type { FhirObservation } from "@/lib/fhir/types";
+import {
+ categoryConcept,
+ codeableFromMapping,
+ latestReading,
+ observationIdSequence,
+ patientRef,
+ reportingPeriod,
+} from "@/lib/fhir/resources/common";
+import {
+ glucoseClinicalObservations,
+ glucoseContextObservations,
+} from "@/lib/fhir/resources/glucose";
+import { labObservations } from "@/lib/fhir/resources/labs";
+
+/** v1.10.0 — English display per persisted wellness-score type (FHIR
+ * text-only concept; the score has no published LOINC term). */
+const WELLNESS_SCORE_DISPLAY: Record = {
+ RECOVERY_SCORE: "Recovery score",
+ STRESS_SCORE: "Stress score",
+ STRAIN_SCORE: "Strain score",
+};
+
+/** One latest reading per single-value measurement type. */
+function measurementObservations(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const observations: FhirObservation[] = [];
+ for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) {
+ // BMI is emitted once by the computed-BMI block below (matching the PDF's
+ // BMI line); skip the stored BODY_MASS_INDEX series here to avoid a
+ // duplicate Observation.
+ if (type === "BODY_MASS_INDEX") continue;
+ const reading = latestReading(data, type);
+ if (!reading) continue;
+ // SLEEP_DURATION is stored in MINUTES; the iOS-locked UCUM unit is `h`, so
+ // emit the value in hours to keep value and unit consistent. The PDF reads
+ // the raw series independently and is unaffected.
+ const value =
+ type === "SLEEP_DURATION"
+ ? Math.round((reading.value / 60) * 100) / 100
+ : reading.value;
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept(mapping.category)],
+ code: codeableFromMapping(mapping),
+ subject: patientRef,
+ effectiveDateTime: reading.measuredAt,
+ // The registry's display unit is only stamped as a UCUM `code` when it
+ // IS one — `dB[A]` reads as decibels to a person and as nothing to a
+ // UCUM parser.
+ valueQuantity: ucumQuantity(value, mapping.unit),
+ });
+ }
+ return observations;
+}
+
+/** The blood-pressure panel, present only when both components have a reading. */
+function bloodPressureObservation(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const sys = latestReading(data, "BLOOD_PRESSURE_SYS");
+ const dia = latestReading(data, "BLOOD_PRESSURE_DIA");
+ if (!sys || !dia) return [];
+ return [
+ {
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("vital-signs")],
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: BP_PANEL_LOINC,
+ display: "Blood pressure panel",
+ },
+ ],
+ text: "Blood pressure",
+ },
+ subject: patientRef,
+ // Use the systolic reading's timestamp as the panel effective time.
+ effectiveDateTime: sys.measuredAt,
+ component: [
+ {
+ code: {
+ coding: [
+ { system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" },
+ ],
+ },
+ valueQuantity: ucumQuantity(sys.value, BP_UNIT),
+ },
+ {
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: BP_DIA_LOINC,
+ display: "Diastolic",
+ },
+ ],
+ },
+ valueQuantity: ucumQuantity(dia.value, BP_UNIT),
+ },
+ ],
+ },
+ ];
+}
+
+/** The computed BMI, matching the PDF's BMI line. */
+function bmiObservation(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ if (data.bmi === null || data.bmi === undefined) return [];
+ return [
+ {
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("vital-signs")],
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: "39156-5",
+ display: "Body mass index (BMI) [Ratio]",
+ },
+ ],
+ text: "Body mass index (BMI) [Ratio]",
+ },
+ subject: patientRef,
+ effectivePeriod: reportingPeriod(data),
+ valueQuantity: ucumQuantity(data.bmi, "kg/m2"),
+ },
+ ];
+}
+
+/** One adherence rate per medication with a scheduled dose in the window. */
+function adherenceObservations(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const observations: FhirObservation[] = [];
+ const period = reportingPeriod(data);
+ for (const [name, comp] of Object.entries(data.compliance)) {
+ if (comp.total <= 0) continue;
+ // Integer percent — the one canonical rounding the app card + PDF use, so
+ // a clinician sees the same adherence figure on every surface.
+ const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("activity")],
+ code: {
+ coding: [
+ {
+ system: LOINC_SYSTEM,
+ code: MEDICATION_ADHERENCE_LOINC,
+ display: "Medication adherence",
+ },
+ ],
+ text: `Medication adherence — ${name}`,
+ },
+ subject: patientRef,
+ effectivePeriod: period,
+ valueQuantity: ucumQuantity(rate, "%"),
+ });
+ }
+ return observations;
+}
+
+/** The opt-in mood average; absent when the toggle is off. */
+function moodObservation(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ if (!data.mood) return [];
+ return [
+ {
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("vital-signs")],
+ code: {
+ coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }],
+ text: "Mood (average over period)",
+ },
+ subject: patientRef,
+ effectivePeriod: reportingPeriod(data),
+ valueQuantity: ucumQuantity(
+ Math.round(data.mood.avg * 10) / 10,
+ "{score}",
+ ),
+ },
+ ];
+}
+
+/**
+ * v1.10.0 — the server-derived nightly scores (recovery / stress / strain).
+ * They have no published LOINC term and are NOT clinical findings, so each is
+ * emitted under the `survey` category with a text-only concept and an explicit
+ * "descriptive, not a clinical assessment" note — a physician's FHIR viewer
+ * never mistakes a band for a diagnosis. Absent when the aggregator emitted no
+ * scores.
+ */
+function wellnessObservations(
+ data: DoctorReportData,
+ nextId: () => string,
+): FhirObservation[] {
+ const observations: FhirObservation[] = [];
+ for (const s of data.wellnessScores ?? []) {
+ observations.push({
+ resourceType: "Observation",
+ id: nextId(),
+ status: "final",
+ category: [categoryConcept("survey")],
+ code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" },
+ subject: patientRef,
+ effectiveDateTime: s.latestAt,
+ valueQuantity: ucumQuantity(s.latest, "{score}"),
+ note: [
+ {
+ text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.",
+ },
+ ],
+ });
+ }
+ return observations;
+}
+
+/**
+ * Emit every `Observation` from the aggregated report data, in the canonical
+ * order.
+ */
+export function observationsFromReportData(
+ data: DoctorReportData,
+): FhirObservation[] {
+ const nextId = observationIdSequence();
+ return [
+ ...measurementObservations(data, nextId),
+ ...bloodPressureObservation(data, nextId),
+ ...bmiObservation(data, nextId),
+ ...glucoseContextObservations(data, nextId),
+ ...glucoseClinicalObservations(data, nextId),
+ ...labObservations(data, nextId),
+ ...adherenceObservations(data, nextId),
+ ...moodObservation(data, nextId),
+ ...wellnessObservations(data, nextId),
+ ];
+}
diff --git a/src/lib/fhir/resources/patient.ts b/src/lib/fhir/resources/patient.ts
new file mode 100644
index 000000000..47f14bc93
--- /dev/null
+++ b/src/lib/fhir/resources/patient.ts
@@ -0,0 +1,65 @@
+/**
+ * The `Patient` resource: display name, administrative gender, birth date and
+ * the KVNR identifier. Every absent field collapses its slot.
+ */
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import type { FhirPatient } from "@/lib/fhir/types";
+import {
+ KVNR_SYSTEM,
+ PATIENT_RESOURCE_ID,
+ type FhirPatientIdentity,
+} from "@/lib/fhir/resources/common";
+
+/**
+ * Stored gender → the R4 `administrative-gender` code.
+ *
+ * The stored value is the free-text projection of the app's own three-member
+ * enum. The mapping is exhaustive over it and closed to everything else: an
+ * unrecognised string omits the element rather than picking a side, because a
+ * clinical document that names a gender the person did not record states
+ * something false about them.
+ *
+ * `unknown` is deliberately never emitted. In FHIR it means "asked, and the
+ * answer is not known" — a positive claim about an interaction that did not
+ * happen. An unrecorded field is honestly absent instead.
+ */
+const ADMINISTRATIVE_GENDER: Record = {
+ MALE: "male",
+ FEMALE: "female",
+ OTHER: "other",
+};
+
+/** The R4 gender code for a stored value, or `null` to omit the element. */
+export function administrativeGender(
+ stored: string | null | undefined,
+): FhirPatient["gender"] | null {
+ if (!stored) return null;
+ return ADMINISTRATIVE_GENDER[stored] ?? null;
+}
+
+/**
+ * Emit the `Patient` resource from the aggregated report data + decrypted
+ * identity.
+ */
+export function patientResource(
+ data: DoctorReportData,
+ identity: FhirPatientIdentity,
+): FhirPatient {
+ const patient: FhirPatient = {
+ resourceType: "Patient",
+ id: PATIENT_RESOURCE_ID,
+ };
+ const displayName = data.patient.fullName ?? data.patient.username ?? null;
+ if (displayName) patient.name = [{ text: displayName }];
+ const gender = administrativeGender(data.patient.gender);
+ if (gender) patient.gender = gender;
+ if (data.patient.dateOfBirth) {
+ patient.birthDate = data.patient.dateOfBirth.slice(0, 10);
+ }
+ if (identity.insuranceNumber) {
+ patient.identifier = [
+ { system: KVNR_SYSTEM, value: identity.insuranceNumber },
+ ];
+ }
+ return patient;
+}
diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts
index 9094504ed..87ddb0a9c 100644
--- a/src/lib/fhir/rest.ts
+++ b/src/lib/fhir/rest.ts
@@ -15,14 +15,17 @@
import { NextResponse } from "next/server";
import { decrypt } from "@/lib/crypto";
+import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { prisma } from "@/lib/db";
import {
collectDoctorReportData,
normaliseDateRange,
type DoctorReportData,
} from "@/lib/doctor-report-data";
+import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
import { isModuleEnabled } from "@/lib/modules/gate";
+import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import type {
FhirBundleEntry,
FhirBundleLink,
@@ -43,15 +46,19 @@ export const FHIR_READ_SCOPE = "fhir:read";
*/
export const FHIR_REST_RESOURCE_TYPES = [
"Patient",
+ "Coverage",
"Observation",
"MedicationStatement",
"MedicationAdministration",
] as const;
/**
- * The whole-record operation the REST face exposes (`GET /api/fhir/$everything`),
- * returning the existing `type: "document"` Bundle. Surfaced in discovery so a
- * client knows the snapshot pull exists alongside the per-resource searches.
+ * The whole-record operation the REST face exposes
+ * (`GET /api/fhir/Patient/$everything`). It returns a `type: "searchset"`
+ * Bundle carrying every resource the document export carries, entry-for-entry,
+ * under the same `urn:uuid` identities — so a reference between two of them
+ * still resolves inside the response. Surfaced in discovery so a client knows
+ * the snapshot pull exists alongside the per-resource searches.
*/
export const FHIR_EVERYTHING_OPERATION = "$everything";
@@ -127,6 +134,27 @@ export function searchsetResponse(
const entry: FhirBundleEntry[] = page.map((resource) => ({
fullUrl: `${requestUrl.origin}/api/fhir/${resource.resourceType}/${resource.id}`,
resource,
+ }));
+ return searchsetResponseFromEntries(requestUrl, entry, total, count, offset);
+}
+
+/**
+ * The same `searchset` wrapper for a page whose entry identities are already
+ * decided. `$everything` uses it to hand back the document bundle's own
+ * `urn:uuid` fullUrls: those resources are not all individually addressable
+ * (only the routed types have a `/{type}/{id}`), and minting a REST URL for a
+ * Composition or an Encounter would advertise an address that 404s.
+ */
+export function searchsetResponseFromEntries(
+ requestUrl: URL,
+ page: ReadonlyArray>,
+ total: number,
+ count: number,
+ offset: number,
+): NextResponse {
+ const entry: FhirBundleEntry[] = page.map((e) => ({
+ fullUrl: e.fullUrl,
+ resource: e.resource,
search: { mode: "match" },
}));
@@ -145,6 +173,23 @@ export function searchsetResponse(
});
}
+/**
+ * Answer a `GET /{Type}/{id}` read: the resource itself when the caller's own
+ * record holds that id, an R4 `not-found` OperationOutcome otherwise. There is
+ * no cross-user lookup — `candidates` is already the caller's set, so an id
+ * belonging to someone else is indistinguishable from one that never existed.
+ */
+export function readResponse(
+ candidates: readonly FhirResource[],
+ id: string,
+): NextResponse {
+ const match = candidates.find((resource) => resource.id === id);
+ if (!match) {
+ return operationOutcome(404, "not-found", "No such resource");
+ }
+ return fhirJsonResponse(match);
+}
+
/** Return a raw FHIR resource (e.g. CapabilityStatement) as a FHIR response. */
export function fhirJsonResponse(body: unknown): NextResponse {
return new NextResponse(JSON.stringify(body), {
@@ -176,9 +221,11 @@ export function operationOutcome(
/**
* Resolve everything an emitter needs for a caller: the aggregated report data
- * over the default reporting window, plus the decrypted KVNR identity and the
- * BfArM-ATC flag (derived from the user's locale). Centralised so each
- * resource route is a one-liner over the shared emitters.
+ * over the default reporting window, the decrypted KVNR identity, the
+ * BfArM-ATC flag (derived from the user's locale), and the structured records
+ * (allergies + family history) that live outside the time-windowed
+ * aggregation. Centralised so each resource route is a one-liner over the
+ * shared emitters.
*
* The window matches the document export's default (`normaliseDateRange`
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
@@ -197,6 +244,7 @@ export async function loadFhirContext(userId: string): Promise<{
data: DoctorReportData;
identity: { insuranceNumber: string | null };
germanAtc: boolean;
+ records: FhirRecordInputs;
}> {
if (!(await isModuleEnabled(userId, "doctorReport"))) {
throw new Error(
@@ -205,17 +253,40 @@ export async function loadFhirContext(userId: string): Promise<{
);
}
+ // The REST face cannot ask a human, so it replays the owner's SAVED
+ // selection — the same object reviewed in Settings, no more and no less. An
+ // account that never saved one resolves to the empty selection and this face
+ // serves nothing, which is the honest answer to "give me a record the owner
+ // never scoped". It used to assemble everything at server defaults.
+ const selection = await resolveSavedSelection(userId);
+
const range = normaliseDateRange(undefined);
- const [data, userRow] = await Promise.all([
- collectDoctorReportData(userId, range, {}),
+ const [data, userRow, allergyRows, familyHistoryRows] = await Promise.all([
+ collectDoctorReportData(userId, range, selection),
prisma.user.findUnique({
where: { id: userId },
select: { insuranceNumberEncrypted: true, locale: true },
}),
+ // The structured records are always-available reference data (not
+ // time-windowed), so they ride alongside the report rather than through
+ // the aggregator — the same split `/api/export/health-record` uses.
+ // Owner-scoped, live rows only.
+ selection.has("ALLERGIES")
+ ? prisma.allergy.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { createdAt: "desc" },
+ })
+ : Promise.resolve([]),
+ selection.has("FAMILY_HISTORY")
+ ? prisma.familyHistoryEntry.findMany({
+ where: { userId, deletedAt: null },
+ orderBy: { createdAt: "desc" },
+ })
+ : Promise.resolve([]),
]);
let insuranceNumber: string | null = null;
- if (userRow?.insuranceNumberEncrypted) {
+ if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
} catch {
@@ -227,5 +298,13 @@ export async function loadFhirContext(userId: string): Promise<{
userRow?.locale ?? "",
);
- return { data, identity: { insuranceNumber }, germanAtc };
+ return {
+ data,
+ identity: { insuranceNumber },
+ germanAtc,
+ records: {
+ allergies: allergyRows.map(toAllergyDTO),
+ familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
+ },
+ };
}
diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts
index 95e29c9eb..a563dd8cb 100644
--- a/src/lib/fhir/types.ts
+++ b/src/lib/fhir/types.ts
@@ -24,8 +24,15 @@ export interface FhirReference {
display?: string;
}
+/**
+ * R4 `Identifier`. `system` is REQUIRED here even though the spec marks it
+ * `0..1`: a bare value with no namespace is not resolvable by a receiver, and
+ * every identifier HealthLog emits (KVNR, IKNR, bundle id) has a real
+ * namespace. Requiring it is what stops a namespace-less identifier from
+ * type-checking.
+ */
export interface FhirIdentifier {
- system?: string;
+ system: string;
value: string;
}
@@ -76,6 +83,14 @@ export interface FhirObservation {
code: FhirCodeableConcept;
subject: FhirReference;
effectiveDateTime?: string;
+ /**
+ * R4 `Observation.effective[x]` as a period. Carries the reporting window
+ * for an aggregate that describes a SPAN rather than an instant (a mean, a
+ * time-in-range share, an adherence rate, a period average). Pinning such a
+ * value to a single `effectiveDateTime` asserts an instant reading that was
+ * never taken.
+ */
+ effectivePeriod?: FhirPeriod;
valueQuantity?: FhirQuantity;
valueInteger?: number;
valueString?: string;
@@ -177,8 +192,13 @@ export interface FhirCoverage {
beneficiary: FhirReference;
/** German KVNR (the subscriber's member id), when present. */
subscriberId?: string;
- /** Insurer(s) — Reference to the contained Organization. */
- payor?: FhirReference[];
+ /**
+ * Insurer(s) — Reference to the contained Organization. R4 marks this `1..*`;
+ * required here so a payor-less Coverage cannot be constructed. A record that
+ * knows only a member id emits no Coverage at all (the member id rides
+ * `Patient.identifier`).
+ */
+ payor: FhirReference[];
}
export interface FhirCompositionSection {
@@ -289,8 +309,24 @@ export interface FhirFamilyMemberHistory {
note?: FhirAnnotation[];
}
+/**
+ * R4 `Device` — the software that authored the document. A `Composition.author`
+ * pointing at a Device that is not in the bundle is a dangling reference, so
+ * the document carries the Device it names. No serial number, no identifier,
+ * no owner: the only honest facts are the software name, its maker and the
+ * running version.
+ */
+export interface FhirDevice {
+ resourceType: "Device";
+ id: string;
+ manufacturer?: string;
+ deviceName?: { name: string; type: "manufacturer-name" }[];
+ version?: { value: string }[];
+}
+
export type FhirResource =
| FhirComposition
+ | FhirDevice
| FhirPatient
| FhirCoverage
| FhirObservation
@@ -303,7 +339,12 @@ export type FhirResource =
| FhirDiagnosticReport;
export interface FhirBundleEntry {
- fullUrl?: string;
+ /**
+ * Absolute URL identifying the entry. Required: `bdl-7` makes it the only
+ * thing an intra-bundle reference can resolve against, and an entry without
+ * one is unreachable from the Composition that names it.
+ */
+ fullUrl: string;
resource: FhirResource;
/** v1.11.0 — `searchset` per-entry search metadata (`mode: "match"`). */
search?: { mode: "match" | "include" | "outcome" };
@@ -312,6 +353,12 @@ export interface FhirBundleEntry {
export interface FhirBundle {
resourceType: "Bundle";
type: "document";
+ /**
+ * `bdl-9` — a document Bundle SHALL carry a persistent identifier with a
+ * system and a value. Minted fresh per build: two exports of the same
+ * record are two documents.
+ */
+ identifier: FhirIdentifier;
timestamp: string;
entry: FhirBundleEntry[];
}
diff --git a/src/lib/fhir/ucum.ts b/src/lib/fhir/ucum.ts
new file mode 100644
index 000000000..7c39bfd6d
--- /dev/null
+++ b/src/lib/fhir/ucum.ts
@@ -0,0 +1,92 @@
+/**
+ * The closed set of UCUM expressions HealthLog is allowed to stamp as a coded
+ * `Quantity.code`.
+ *
+ * A FHIR `Quantity` that carries `system: http://unitsofmeasure.org` promises
+ * that `code` is a valid UCUM expression a receiver can parse and convert.
+ * Copying a display unit into that slot breaks the promise the moment a
+ * display string is not UCUM — the registry's `dB[A]` is exactly that case:
+ * a readable label for A-weighted decibels, not a UCUM term.
+ *
+ * So the rule here is the one the lab path already follows: the human-readable
+ * `unit` is ALWAYS emitted, and `system` + `code` are added only for a token in
+ * this list. An unlisted unit is still conformant — it is just uncoded, which
+ * is the honest statement.
+ *
+ * Membership is asserted, not guessed: every entry is either a UCUM base /
+ * derived expression or an annotation (`{…}`, which UCUM defines as a
+ * dimensionless comment on the unity `1`). Adding a unit means checking it
+ * against the UCUM specification first.
+ */
+
+import { UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
+
+/**
+ * Validated UCUM expressions. Grouped by where they enter the export so a
+ * future signal's unit is checked against the group it belongs to.
+ */
+const UCUM_ALLOWLIST: ReadonlySet = new Set([
+ // Dimensionless + annotations
+ "1",
+ "%",
+ "{score}",
+ "{count}",
+ "{steps}",
+ "{flights}",
+ "{falls}",
+ // Mass / length / ratio
+ "kg",
+ "g",
+ "mg",
+ "ug",
+ "cm",
+ "m",
+ "kg/m2",
+ // Time
+ "ms",
+ "s",
+ "min",
+ "h",
+ "d",
+ "a",
+ // Rates / speed / energy
+ "/min",
+ "m/s",
+ "kcal",
+ "mL",
+ "mL/min/kg",
+ // Clinical
+ "Cel",
+ "mm[Hg]",
+ "mg/dL",
+ "mmol/L",
+]);
+
+/**
+ * True when `unit` is a UCUM expression this exporter has validated. Anything
+ * else — a display label, a locale string, an unchecked new unit — is false,
+ * and its Quantity goes out with `unit` alone.
+ */
+export function isUcumCode(unit: string | null | undefined): boolean {
+ return typeof unit === "string" && UCUM_ALLOWLIST.has(unit);
+}
+
+/**
+ * Build a `Quantity` from a DISPLAY unit — the string the registry or the user
+ * supplies, which is only sometimes a UCUM expression. It is emitted as `unit`
+ * either way; `system` + `code` follow only when the string validates, and
+ * they appear together or not at all (a `system` without a `code` names a code
+ * system nothing in the resource uses).
+ *
+ * The curated paths do NOT go through here: `lab-loinc`'s analyte map and the
+ * dose-unit table already resolve a canonical symbol, and that resolution is
+ * their validation.
+ */
+export function ucumQuantity(
+ value: number,
+ unit: string,
+): { value: number; unit: string; system?: string; code?: string } {
+ return isUcumCode(unit)
+ ? { value, unit, system: UCUM_SYSTEM, code: unit }
+ : { value, unit };
+}
diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts
index b5dc70158..79bfdc9be 100644
--- a/src/lib/mcp/__tests__/prompts.test.ts
+++ b/src/lib/mcp/__tests__/prompts.test.ts
@@ -129,9 +129,12 @@ describe("doctor_visit_summary — grounding + structure", () => {
const text = res.messages[0].content.text;
// The window threads through to the data path (trailing 90 days).
+ // Third argument: the owner's SAVED selection, replayed. This surface
+ // cannot ask a human, so it never invents a scope.
expect(collectDoctorReportData).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ days: 90 }),
+ expect.objectContaining({ version: 2 }),
);
const data = parseData(text);
diff --git a/src/lib/mcp/__tests__/resource-templates.test.ts b/src/lib/mcp/__tests__/resource-templates.test.ts
index 057a70196..c170449b7 100644
--- a/src/lib/mcp/__tests__/resource-templates.test.ts
+++ b/src/lib/mcp/__tests__/resource-templates.test.ts
@@ -227,9 +227,12 @@ describe("healthlog://report/doctor-visit", () => {
CTX,
)) as Record;
+ // Third argument: the owner's SAVED selection, replayed. This surface
+ // cannot ask a human, so it never invents a scope.
expect(collectDoctorReportData).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ days: 90 }),
+ expect.objectContaining({ version: 2 }),
);
expect(result.present).toBe(true);
expect(Array.isArray(result.vitals)).toBe(true);
@@ -247,9 +250,12 @@ describe("healthlog://report/doctor-visit", () => {
} as never);
await template("doctor-visit-windowed").read(CTX, { window: "last30days" });
+ // Third argument: the owner's SAVED selection, replayed. This surface
+ // cannot ask a human, so it never invents a scope.
expect(collectDoctorReportData).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ days: 30 }),
+ expect.objectContaining({ version: 2 }),
);
});
});
diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts
index f65313dc1..c3da9bfbb 100644
--- a/src/lib/mcp/__tests__/resources.test.ts
+++ b/src/lib/mcp/__tests__/resources.test.ts
@@ -233,9 +233,12 @@ describe("healthlog://report/doctor-visit", () => {
CTX,
)) as Record;
+ // Third argument: the owner's SAVED selection, replayed. This surface
+ // cannot ask a human, so it never invents a scope.
expect(collectDoctorReportData).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ days: 90 }),
+ expect.objectContaining({ version: 2 }),
);
expect(result.present).toBe(true);
expect(result).toHaveProperty("vitals");
diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts
index 4e118066d..8877c3fd9 100644
--- a/src/lib/openapi/routes/health-record.ts
+++ b/src/lib/openapi/routes/health-record.ts
@@ -26,6 +26,16 @@ const shareLinkSummary = z.object({
// v1.28 — how many documents this share carries. Never the ids, never the
// bytes; the share serve route is the only decrypt path.
documentCount: z.number(),
+ // Whether this link's frozen scope predates the selection model. Every such
+ // link was revoked on upgrade; the sharing list surfaces this so the owner
+ // knows to re-mint rather than wondering why a working link stopped.
+ needsReselection: z.boolean(),
+ // RETIRED, response-only, serving the constants `false` and `[]`. They
+ // configured a FHIR face that was never built; the request refuses them and
+ // their columns are gone. They stay on the response until the native client
+ // confirms it does not decode them.
+ allowFhirApi: z.boolean(),
+ resourceTypes: z.array(z.string()),
expiresAt: z.string(),
createdAt: z.string(),
revokedAt: z.string().nullable(),
@@ -72,7 +82,7 @@ export const healthRecordPaths: NonNullable = {
tags: ["Export"],
summary: "Generate a health-record export (PDF / FHIR / package)",
description:
- "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422.",
+ "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422.",
requestBody: {
required: true,
content: {
@@ -104,7 +114,7 @@ export const healthRecordPaths: NonNullable = {
tags: ["Export"],
summary: "Create a clinician share link (v1.11.0)",
description:
- "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.",
+ "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.",
requestBody: {
required: true,
content: {
@@ -244,7 +254,7 @@ export const healthRecordPaths: NonNullable = {
tags: ["FHIR"],
summary: "FHIR R4 CapabilityStatement (v1.11.0)",
description:
- "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass).",
+ "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).",
responses: {
"200": {
description: "CapabilityStatement (application/fhir+json).",
@@ -358,12 +368,187 @@ export const healthRecordPaths: NonNullable = {
},
},
},
- "/api/fhir/$everything": {
+ "/api/fhir/Coverage": {
+ get: {
+ tags: ["FHIR"],
+ summary: "FHIR R4 Coverage search",
+ description:
+ "Read-only `searchset` Bundle of the caller's own Coverage resource, when an insurer is recorded and the insurance leaf is in the saved selection. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth.",
+ requestParams: {
+ query: z.object({
+ _count: z.coerce.number().optional(),
+ _offset: z.coerce.number().optional(),
+ }),
+ },
+ responses: {
+ "200": {
+ description: "searchset Bundle (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/fhir/Patient/{id}": {
+ get: {
+ tags: ["FHIR"],
+ summary: "FHIR R4 Patient read",
+ description:
+ "Read-only `Patient` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
+ requestParams: {
+ path: z.object({ id: z.string() }),
+ },
+ responses: {
+ "200": {
+ description: "The resource (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ "404": {
+ description: "OperationOutcome (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/fhir/Coverage/{id}": {
+ get: {
+ tags: ["FHIR"],
+ summary: "FHIR R4 Coverage read",
+ description:
+ "Read-only `Coverage` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
+ requestParams: {
+ path: z.object({ id: z.string() }),
+ },
+ responses: {
+ "200": {
+ description: "The resource (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ "404": {
+ description: "OperationOutcome (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/fhir/Observation/{id}": {
+ get: {
+ tags: ["FHIR"],
+ summary: "FHIR R4 Observation read",
+ description:
+ "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
+ requestParams: {
+ path: z.object({ id: z.string() }),
+ },
+ responses: {
+ "200": {
+ description: "The resource (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ "404": {
+ description: "OperationOutcome (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/fhir/MedicationStatement/{id}": {
+ get: {
+ tags: ["FHIR"],
+ summary: "FHIR R4 MedicationStatement read",
+ description:
+ "Read-only `MedicationStatement` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
+ requestParams: {
+ path: z.object({ id: z.string() }),
+ },
+ responses: {
+ "200": {
+ description: "The resource (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ "404": {
+ description: "OperationOutcome (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/fhir/MedicationAdministration/{id}": {
+ get: {
+ tags: ["FHIR"],
+ summary: "FHIR R4 MedicationAdministration read",
+ description:
+ "Read-only `MedicationAdministration` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
+ requestParams: {
+ path: z.object({ id: z.string() }),
+ },
+ responses: {
+ "200": {
+ description: "The resource (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ "404": {
+ description: "OperationOutcome (application/fhir+json).",
+ content: {
+ "application/fhir+json": {
+ schema: z.string().meta({ format: "binary" }),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/fhir/Patient/$everything": {
get: {
tags: ["FHIR"],
summary: "FHIR R4 $everything (v1.11.0)",
description:
- "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
+ "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts
new file mode 100644
index 000000000..8a36daf46
--- /dev/null
+++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts
@@ -0,0 +1,114 @@
+/**
+ * The catalogue's own invariants — the ones the compiler cannot state.
+ *
+ * The two `Record` types already force a group decision for every measurement
+ * type and every structured leaf. What they cannot force is that each leaf has
+ * a label a human can read, that the label resolves in the shipped bundle, and
+ * that the fenced tier stays last. A leaf with no label renders as a raw enum
+ * string on a clinical document.
+ *
+ * Mutation checks:
+ * - drop `PAIN_NRS` from `MEASUREMENT_LEAF_GROUP` → typecheck fails first,
+ * and with the type widened the count assertion goes red.
+ * - point `STRUCTURED_LEAF_LABEL_KEYS.MOOD` at a key that is not in the
+ * bundle → "resolves a label for every leaf" goes red naming MOOD.
+ * - move `"sensitive"` out of last place in `REPORT_GROUP_ORDER` → the order
+ * assertion goes red.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import {
+ ALL_LEAF_IDS,
+ MEASUREMENT_LEAF_IDS,
+ REPORT_GROUPS,
+ REPORT_GROUP_LABEL_KEYS,
+ REPORT_GROUP_ORDER,
+ SENSITIVE_LEAF_IDS,
+ STRUCTURED_LEAF_IDS,
+ leafLabelKey,
+} from "../catalogue";
+import { STANDARD_TEMPLATE_LEAVES } from "../template";
+
+const EN = JSON.parse(
+ readFileSync(
+ join(__dirname, "..", "..", "..", "..", "messages", "en.json"),
+ "utf8",
+ ),
+) as Record;
+
+function resolves(key: string): boolean {
+ let node: unknown = EN;
+ for (const part of key.split(".")) {
+ if (typeof node !== "object" || node === null) return false;
+ node = (node as Record)[part];
+ }
+ return typeof node === "string" && node.length > 0;
+}
+
+describe("report selection catalogue", () => {
+ it("carries every measurement type and every structured leaf", () => {
+ expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
+ expect(STRUCTURED_LEAF_IDS).toHaveLength(13);
+ expect(ALL_LEAF_IDS).toHaveLength(90);
+ });
+
+ it("resolves a label for every leaf", () => {
+ const unlabelled = ALL_LEAF_IDS.filter(
+ (leaf) => !resolves(leafLabelKey(leaf)),
+ );
+ expect(
+ unlabelled,
+ "these leaves have no label in messages/en.json and would render as a " +
+ "raw enum string on a clinical document",
+ ).toEqual([]);
+ });
+
+ it("resolves a label for every group", () => {
+ const unlabelled = REPORT_GROUP_ORDER.filter(
+ (group) => !resolves(REPORT_GROUP_LABEL_KEYS[group]),
+ );
+ expect(unlabelled).toEqual([]);
+ });
+
+ it("orders the groups the way the panel and the PDF read them", () => {
+ expect(REPORT_GROUPS.map((g) => g.id)).toEqual([...REPORT_GROUP_ORDER]);
+ // The fenced tier is last, so it sits below every group control rather
+ // than among them.
+ expect(REPORT_GROUP_ORDER[REPORT_GROUP_ORDER.length - 1]).toBe("sensitive");
+ });
+});
+
+describe("template purity", () => {
+ it("keeps the shipped template clear of the fenced tier", () => {
+ const overlap = STANDARD_TEMPLATE_LEAVES.filter((leaf) =>
+ SENSITIVE_LEAF_IDS.includes(leaf),
+ );
+ expect(
+ overlap,
+ "the shipped template must never preselect a fenced leaf",
+ ).toEqual([]);
+ });
+
+ it("names every template leaf in the catalogue", () => {
+ const unknown = STANDARD_TEMPLATE_LEAVES.filter(
+ (leaf) => !ALL_LEAF_IDS.includes(leaf),
+ );
+ expect(unknown).toEqual([]);
+ });
+
+ it("fences exactly the seven leaves the design names", () => {
+ expect([...SENSITIVE_LEAF_IDS].sort()).toEqual(
+ [
+ "CYCLE",
+ "FAMILY_HISTORY",
+ "GAD7_SCORE",
+ "MOOD",
+ "PHQ9_SCORE",
+ "SCI_SCORE",
+ "WHO5_SCORE",
+ ].sort(),
+ );
+ });
+});
diff --git a/src/lib/report-selection/__tests__/panel-state.test.ts b/src/lib/report-selection/__tests__/panel-state.test.ts
new file mode 100644
index 000000000..02456f10d
--- /dev/null
+++ b/src/lib/report-selection/__tests__/panel-state.test.ts
@@ -0,0 +1,89 @@
+/**
+ * The picker's pure state: derived group states, the group toggle, and the
+ * scope line.
+ *
+ * Two properties are load-bearing beyond the panel. A group control must never
+ * reach a fenced leaf, and the scope line must restate every fenced inclusion
+ * by name rather than folding it into a count — that line is the consent check.
+ *
+ * Mutation checks:
+ * - remove the `SENSITIVE_GROUP_ID` early return in `toggleGroup` → "no group
+ * control can reach a fenced leaf" goes red.
+ * - drop the `sensitiveLabelKeys.push(...)` branch in `scopeSummary` → "the
+ * scope line names every fenced inclusion" goes red.
+ * - make `groupCheckState` return "all" when some leaves are on → "a partly
+ * selected group reads as mixed" goes red.
+ */
+import { describe, it, expect } from "vitest";
+
+import {
+ groupCheckState,
+ groupCount,
+ scopeSummary,
+ toggleGroup,
+ toggleLeaf,
+} from "../panel-state";
+import { REPORT_GROUPS, type ReportLeafId } from "../catalogue";
+
+const vitals = REPORT_GROUPS.find((g) => g.id === "vitals")!;
+const sensitive = REPORT_GROUPS.find((g) => g.id === "sensitive")!;
+
+describe("scope picker state", () => {
+ it("derives none / mixed / all from the leaves alone", () => {
+ expect(groupCheckState("vitals", new Set())).toBe("none");
+ expect(groupCheckState("vitals", new Set([vitals.leaves[0]]))).toBe(
+ "mixed",
+ );
+ expect(groupCheckState("vitals", new Set(vitals.leaves))).toBe("all");
+ });
+
+ it("counts what is on against what exists", () => {
+ const count = groupCount("vitals", new Set([vitals.leaves[0]]));
+ expect(count).toEqual({ on: 1, total: vitals.leaves.length });
+ });
+
+ it("turns a mixed group fully on, and a full group fully off", () => {
+ const partial = new Set([vitals.leaves[0]]);
+ const on = toggleGroup(partial, "vitals");
+ expect(groupCheckState("vitals", on)).toBe("all");
+ const off = toggleGroup(on, "vitals");
+ expect(groupCheckState("vitals", off)).toBe("none");
+ });
+
+ it("restores the previous pattern when a group comes back on", () => {
+ const original = new Set([
+ vitals.leaves[0],
+ vitals.leaves[1],
+ ]);
+ const off = toggleGroup(original, "vitals", original);
+ const back = toggleGroup(off, "vitals", original);
+ expect([...back].sort()).toEqual([...original].sort());
+ });
+
+ it("lets no group control reach a fenced leaf", () => {
+ const before = new Set();
+ const after = toggleGroup(before, "sensitive");
+ expect([...after]).toEqual([]);
+ for (const leaf of sensitive.leaves) {
+ expect(after.has(leaf)).toBe(false);
+ }
+ });
+
+ it("still lets a single fenced leaf be chosen one at a time", () => {
+ const one = toggleLeaf(new Set(), "MOOD");
+ expect(one.has("MOOD")).toBe(true);
+ expect(one.has("CYCLE")).toBe(false);
+ });
+
+ it("names every fenced inclusion in the scope line", () => {
+ const summary = scopeSummary(new Set(["WEIGHT", "MOOD"]));
+ expect(summary.total).toBe(2);
+ expect(summary.groups).toBe(2);
+ expect(summary.sensitiveLabelKeys).toEqual(["reportSelection.leafMood"]);
+ });
+
+ it("says nothing is chosen when nothing is", () => {
+ const summary = scopeSummary(new Set());
+ expect(summary).toEqual({ total: 0, groups: 0, sensitiveLabelKeys: [] });
+ });
+});
diff --git a/src/lib/report-selection/__tests__/selection.test.ts b/src/lib/report-selection/__tests__/selection.test.ts
new file mode 100644
index 000000000..cad8a73f4
--- /dev/null
+++ b/src/lib/report-selection/__tests__/selection.test.ts
@@ -0,0 +1,99 @@
+/**
+ * The selection type's contract: what each of the three minting functions does
+ * with input it does not recognise, and that absence never becomes inclusion.
+ *
+ * Mutation checks:
+ * - make `selectionFromStoredBlob` fall back to `STANDARD_TEMPLATE_LEAVES`
+ * instead of the empty selection → "an unreadable blob resolves to
+ * nothing" goes red.
+ * - make `selectionFromRequest` drop unknown ids instead of rejecting →
+ * "a request naming an id this build does not know is refused" goes red.
+ * - drop the `.filter(isReportLeafId)` in `selectionFromStoredBlob` → "a
+ * stored blob naming a retired leaf drops it" goes red.
+ */
+import { describe, it, expect } from "vitest";
+
+import {
+ EMPTY_REPORT_SELECTION,
+ isEmptySelection,
+ selectionFromLeaves,
+ selectionFromRequest,
+ selectionFromStoredBlob,
+ selectionToBlob,
+} from "../selection";
+import { ALL_LEAF_IDS } from "../catalogue";
+
+describe("report selection", () => {
+ it("treats absence as exclusion", () => {
+ const selection = selectionFromLeaves(["WEIGHT"]);
+ expect(selection.has("WEIGHT")).toBe(true);
+ // Everything else in the catalogue is out, without anyone saying so.
+ for (const leaf of ALL_LEAF_IDS) {
+ if (leaf === "WEIGHT") continue;
+ expect(selection.has(leaf)).toBe(false);
+ }
+ });
+
+ it("orders leaves by the catalogue, not by the caller", () => {
+ const selection = selectionFromLeaves(["LAB_RESULTS", "PULSE", "WEIGHT"]);
+ const expected = ALL_LEAF_IDS.filter((leaf) =>
+ ["LAB_RESULTS", "PULSE", "WEIGHT"].includes(leaf),
+ );
+ expect(selection.leaves).toEqual(expected);
+ });
+
+ it("refuses a request naming an id this build does not know", () => {
+ const result = selectionFromRequest({
+ v: 2,
+ leaves: ["WEIGHT", "SOMETHING_NEW"],
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.unknownLeaves).toEqual(["SOMETHING_NEW"]);
+ }
+ });
+
+ it("accepts a request naming only known ids", () => {
+ const result = selectionFromRequest({ v: 2, leaves: ["WEIGHT"] });
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.selection.leaves).toEqual(["WEIGHT"]);
+ });
+
+ it("drops a retired leaf from a stored blob rather than failing", () => {
+ const selection = selectionFromStoredBlob({
+ v: 2,
+ leaves: ["WEIGHT", "SOMETHING_RETIRED"],
+ });
+ expect(selection.leaves).toEqual(["WEIGHT"]);
+ });
+
+ it("resolves an unreadable blob to nothing, never to a default", () => {
+ for (const raw of [
+ null,
+ undefined,
+ {},
+ { v: 1, leaves: ["WEIGHT"] },
+ { bp: true, weight: true, pulse: true },
+ "WEIGHT",
+ [],
+ ]) {
+ const selection = selectionFromStoredBlob(raw);
+ expect(isEmptySelection(selection), `blob ${JSON.stringify(raw)}`).toBe(
+ true,
+ );
+ }
+ });
+
+ it("round-trips through the stored shape", () => {
+ const selection = selectionFromLeaves(["WEIGHT", "LAB_RESULTS"]);
+ const blob = selectionToBlob(selection);
+ expect(selectionFromStoredBlob(blob).leaves).toEqual(selection.leaves);
+ });
+
+ it("has an empty selection that admits nothing", () => {
+ expect(isEmptySelection(EMPTY_REPORT_SELECTION)).toBe(true);
+ for (const leaf of ALL_LEAF_IDS) {
+ expect(EMPTY_REPORT_SELECTION.has(leaf)).toBe(false);
+ }
+ });
+});
diff --git a/src/lib/report-selection/saved-profile.ts b/src/lib/report-selection/saved-profile.ts
index 8dc7f6fd6..b0615a71e 100644
--- a/src/lib/report-selection/saved-profile.ts
+++ b/src/lib/report-selection/saved-profile.ts
@@ -40,7 +40,11 @@ export const savedReportProfileSchema = reportSelectionSchema.extend({
export type SavedReportProfile = z.infer;
-/** What the panel seeds from when the account has never saved a profile. */
+/**
+ * The render choices the panel starts from when the account has never saved a
+ * profile. Presentation only — this is deliberately NOT a scope fallback;
+ * there is no such thing here, because a scope nobody chose is the defect.
+ */
export const SAVED_PROFILE_FALLBACK = {
format: "pdf",
rangeDays: 90,
diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json
index 19cd326e6..716887d9b 100644
--- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json
+++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json
@@ -250,5 +250,17 @@
"display": "Waist to height ratio",
"unit": "1",
"category": "vital-signs"
+ },
+ "WHO5_SCORE": {
+ "loinc": null,
+ "display": "WHO-5 well-being index total score",
+ "unit": "{score}",
+ "category": "survey"
+ },
+ "SCI_SCORE": {
+ "loinc": null,
+ "display": "Sleep Condition Indicator total score",
+ "unit": "{score}",
+ "category": "survey"
}
}
diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts
index 84dfd2670..afb003c53 100644
--- a/src/lib/signals/registry.ts
+++ b/src/lib/signals/registry.ts
@@ -1275,9 +1275,12 @@ export const SIGNALS: Record = {
// ── v1.27.9 — WHO-5 well-being + Sleep Condition Indicator totals ───────
// Same privacy posture as PHQ-9 / GAD-7 (off Coach snapshot + MCP; item
// content never rides a Measurement row). Direction inverts: HIGHER is
- // better for both. No verified total-score LOINC exists for either, so no
- // FHIR coding is attached (the export skips uncoded signals rather than
- // shipping an invented code).
+ // better for both. No verified total-score LOINC exists for either, so both
+ // carry the `loinc: null` text-only survey concept the physical signals
+ // already use — an uncoded finding a clinician can read, never an invented
+ // code. Without a `fhir` facet the FHIR adapter drops a signal entirely,
+ // which is how a total the PDF and the share view show went missing from
+ // every FHIR output.
WHO5_SCORE: {
key: "WHO5_SCORE",
kind: "score",
@@ -1295,6 +1298,13 @@ export const SIGNALS: Record = {
coachSnapshot: false,
mcp: false,
},
+ fhir: {
+ // No verified total-score LOINC — local text concept, `{score}` UCUM.
+ loinc: null,
+ display: "WHO-5 well-being index total score",
+ unit: "{score}",
+ category: "survey",
+ },
},
SCI_SCORE: {
key: "SCI_SCORE",
@@ -1312,6 +1322,13 @@ export const SIGNALS: Record = {
coachSnapshot: false,
mcp: false,
},
+ fhir: {
+ // No verified total-score LOINC — local text concept, `{score}` UCUM.
+ loinc: null,
+ display: "Sleep Condition Indicator total score",
+ unit: "{score}",
+ category: "survey",
+ },
},
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
GRIP_STRENGTH: {
diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts
deleted file mode 100644
index e8073ab02..000000000
--- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- DEFAULT_DOCTOR_REPORT_PREFS,
- doctorReportPrefsSchema,
- parseDoctorReportPrefs,
- resolveDoctorReportPrefs,
-} from "../doctor-report-prefs";
-
-/**
- * v1.4.25 W6c — per-user Doctor-Report section toggles.
- */
-describe("doctorReportPrefsSchema", () => {
- it("accepts an empty object (every key is optional)", () => {
- const out = doctorReportPrefsSchema.parse({});
- expect(out).toEqual({});
- });
-
- it("accepts the full shape and round-trips it", () => {
- const input = {
- bp: true,
- weight: false,
- pulse: true,
- bmi: true,
- mood: false,
- compliance: true,
- sleep: false,
- };
- const out = doctorReportPrefsSchema.parse(input);
- expect(out).toEqual(input);
- });
-
- it("accepts a partial update (only the toggled keys)", () => {
- const out = doctorReportPrefsSchema.parse({ mood: true });
- expect(out).toEqual({ mood: true });
- });
-
- it("rejects a non-boolean value", () => {
- const result = doctorReportPrefsSchema.safeParse({ bp: "yes" });
- expect(result.success).toBe(false);
- });
-});
-
-describe("parseDoctorReportPrefs", () => {
- it("returns defaults for null input", () => {
- expect(parseDoctorReportPrefs(null)).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
- });
-
- it("returns defaults for undefined input", () => {
- expect(parseDoctorReportPrefs(undefined)).toEqual(
- DEFAULT_DOCTOR_REPORT_PREFS,
- );
- });
-
- it("defaults mood to OFF (privacy directive per the maintainer)", () => {
- const out = parseDoctorReportPrefs(null);
- expect(out.mood).toBe(false);
- });
-
- it("defaults glucose to ON (per-report control, parity with pre-toggle behaviour)", () => {
- const out = parseDoctorReportPrefs(null);
- expect(out.glucose).toBe(true);
- });
-
- it("accepts an explicit glucose=false partial update", () => {
- const out = parseDoctorReportPrefs({ glucose: false });
- expect(out.glucose).toBe(false);
- // Other sections keep their defaults.
- expect(out.labs).toBe(true);
- });
-
- it("defaults labs to ON (recorded to share with a clinician)", () => {
- const out = parseDoctorReportPrefs(null);
- expect(out.labs).toBe(true);
- });
-
- it("fills missing keys from defaults", () => {
- const out = parseDoctorReportPrefs({ bp: false });
- expect(out).toEqual({
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- bp: false,
- });
- });
-
- it("returns defaults for malformed input (forward-compat fallback)", () => {
- expect(parseDoctorReportPrefs({ bp: "nope" })).toEqual(
- DEFAULT_DOCTOR_REPORT_PREFS,
- );
- expect(parseDoctorReportPrefs("not an object")).toEqual(
- DEFAULT_DOCTOR_REPORT_PREFS,
- );
- });
-});
-
-describe("resolveDoctorReportPrefs", () => {
- it("layers a partial update over current persisted values", () => {
- const current = {
- bp: false,
- weight: false,
- pulse: false,
- bmi: false,
- mood: true,
- compliance: false,
- sleep: false,
- glucose: false,
- cycle: false,
- labs: false,
- allergies: false,
- familyHistory: false,
- };
- const out = resolveDoctorReportPrefs(current, { mood: false });
- // Keys the persisted row predates (this change added the measurement-group,
- // medication-list and screener flags) fill from the defaults, so the
- // resolved shape is always complete for the aggregator.
- expect(out).toEqual({
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...current,
- mood: false,
- });
- });
-
- it("layers a partial update over defaults when current is null", () => {
- const out = resolveDoctorReportPrefs(null, { mood: true });
- expect(out).toEqual({ ...DEFAULT_DOCTOR_REPORT_PREFS, mood: true });
- });
-});
diff --git a/src/lib/validations/__tests__/health-record-export.test.ts b/src/lib/validations/__tests__/health-record-export.test.ts
index d79ad1472..d38e3196a 100644
--- a/src/lib/validations/__tests__/health-record-export.test.ts
+++ b/src/lib/validations/__tests__/health-record-export.test.ts
@@ -1,33 +1,107 @@
+/**
+ * The export request contract.
+ *
+ * `.strict()` plus a required `selection` is the whole point: a body that says
+ * nothing about scope is refused rather than resolved to a server default, and
+ * the retired grouped `sections` shape is refused by name rather than folded
+ * into one. Silently accepting a payload that does not express a scope is the
+ * defect this release removes.
+ *
+ * Mutation checks:
+ * - make `selection` `.optional()` → "refuses a body that states no scope"
+ * goes red.
+ * - drop `.strict()` → "refuses the retired sections shape by name" and
+ * "rejects a userId smuggled into the body" go red.
+ * - re-add `includeAiSummary` to the schema → "refuses the retired AI summary
+ * flag" goes red.
+ */
import { describe, it, expect } from "vitest";
-import {
- exportSelectionSchema,
- toDoctorReportPrefs,
-} from "../health-record-export";
+
+import { exportSelectionSchema } from "../health-record-export";
+
+const SELECTION = { v: 2, leaves: ["WEIGHT"] };
describe("exportSelectionSchema", () => {
it("accepts a minimal valid payload", () => {
- const parsed = exportSelectionSchema.safeParse({ format: "pdf" });
+ const parsed = exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: SELECTION,
+ });
expect(parsed.success).toBe(true);
});
it("accepts each supported format", () => {
for (const format of ["pdf", "fhir", "package"]) {
- expect(exportSelectionSchema.safeParse({ format }).success).toBe(true);
+ expect(
+ exportSelectionSchema.safeParse({ format, selection: SELECTION })
+ .success,
+ ).toBe(true);
}
});
it("rejects an unknown format", () => {
- const parsed = exportSelectionSchema.safeParse({ format: "xml" });
+ const parsed = exportSelectionSchema.safeParse({
+ format: "xml",
+ selection: SELECTION,
+ });
expect(parsed.success).toBe(false);
});
it("rejects a missing format", () => {
- expect(exportSelectionSchema.safeParse({}).success).toBe(false);
+ expect(
+ exportSelectionSchema.safeParse({ selection: SELECTION }).success,
+ ).toBe(false);
+ });
+
+ it("refuses a body that states no scope", () => {
+ // An omitted selection is not "everything" and not "the defaults" — it is
+ // a caller who did not say, and the route has nothing honest to serve.
+ const parsed = exportSelectionSchema.safeParse({ format: "pdf" });
+ expect(parsed.success).toBe(false);
+ });
+
+ it("accepts an explicitly empty scope", () => {
+ // Saying "nothing" is a legitimate statement, distinct from saying nothing.
+ const parsed = exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: { v: 2, leaves: [] },
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it("refuses a selection at the wrong version", () => {
+ const parsed = exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: { v: 1, leaves: ["WEIGHT"] },
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it("refuses the retired sections shape by name", () => {
+ const parsed = exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: SELECTION,
+ sections: { vitals: { bp: true } },
+ });
+ expect(parsed.success).toBe(false);
+ if (!parsed.success) {
+ expect(JSON.stringify(parsed.error.issues)).toContain("sections");
+ }
+ });
+
+ it("refuses the retired AI summary flag", () => {
+ const parsed = exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: SELECTION,
+ includeAiSummary: true,
+ });
+ expect(parsed.success).toBe(false);
});
it("rejects unknown top-level keys (.strict)", () => {
const parsed = exportSelectionSchema.safeParse({
format: "pdf",
+ selection: SELECTION,
unexpected: true,
});
expect(parsed.success).toBe(false);
@@ -36,81 +110,47 @@ describe("exportSelectionSchema", () => {
it("rejects a userId smuggled into the body", () => {
const parsed = exportSelectionSchema.safeParse({
format: "pdf",
+ selection: SELECTION,
userId: "someone-else",
});
expect(parsed.success).toBe(false);
});
- it("rejects an unknown key inside range (.strict)", () => {
- const parsed = exportSelectionSchema.safeParse({
- format: "fhir",
- range: { days: 30, evil: 1 },
- });
- expect(parsed.success).toBe(false);
- });
-
it("enforces the range.days cap (1..365)", () => {
expect(
- exportSelectionSchema.safeParse({ format: "pdf", range: { days: 0 } })
- .success,
- ).toBe(false);
- expect(
- exportSelectionSchema.safeParse({ format: "pdf", range: { days: 366 } })
- .success,
- ).toBe(false);
- expect(
- exportSelectionSchema.safeParse({ format: "pdf", range: { days: 365 } })
- .success,
+ exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: SELECTION,
+ range: { days: 365 },
+ }).success,
).toBe(true);
+ expect(
+ exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: SELECTION,
+ range: { days: 366 },
+ }).success,
+ ).toBe(false);
});
- it("returns all issues, not just the first", () => {
+ it("accepts an absolute custom window", () => {
const parsed = exportSelectionSchema.safeParse({
- format: "xml",
- range: { days: 9999 },
- unknownKey: 1,
+ format: "pdf",
+ selection: SELECTION,
+ range: {
+ startDate: "2026-01-01T00:00:00+00:00",
+ endDate: "2026-02-01T00:00:00+00:00",
+ },
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it("caps the practice name", () => {
+ const parsed = exportSelectionSchema.safeParse({
+ format: "pdf",
+ selection: SELECTION,
+ practiceName: "x".repeat(121),
});
expect(parsed.success).toBe(false);
- if (!parsed.success) {
- expect(parsed.error.issues.length).toBeGreaterThan(1);
- }
- });
-});
-
-describe("toDoctorReportPrefs", () => {
- it("defaults to every section on except mood when no selection given", () => {
- const prefs = toDoctorReportPrefs(undefined);
- expect(prefs.weight).toBe(true);
- expect(prefs.bp).toBe(true);
- expect(prefs.mood).toBe(false);
- });
-
- it("keeps mood off unless explicitly true", () => {
- expect(toDoctorReportPrefs({ mood: false }).mood).toBe(false);
- expect(toDoctorReportPrefs({}).mood).toBe(false);
- expect(toDoctorReportPrefs({ mood: true }).mood).toBe(true);
- });
-
- it("folds grouped vitals down to the flat shape", () => {
- const prefs = toDoctorReportPrefs({
- vitals: { weight: false, bp: true },
- });
- expect(prefs.weight).toBe(false);
- expect(prefs.bp).toBe(true);
- });
-
- it("maps medication compliance toggle", () => {
- expect(
- toDoctorReportPrefs({ medications: { compliance: false } }).compliance,
- ).toBe(false);
- });
-
- it("threads the glucose section toggle (default ON, off when unchecked)", () => {
- // Default-on preserves the pre-toggle behaviour where glucose always
- // rendered; an explicit false withholds glucose from this report.
- expect(toDoctorReportPrefs(undefined).glucose).toBe(true);
- expect(toDoctorReportPrefs({}).glucose).toBe(true);
- expect(toDoctorReportPrefs({ glucose: true }).glucose).toBe(true);
- expect(toDoctorReportPrefs({ glucose: false }).glucose).toBe(false);
});
});
diff --git a/tests/integration/doctor-report-dense-data.test.ts b/tests/integration/doctor-report-dense-data.test.ts
index f344d343d..6e52a4563 100644
--- a/tests/integration/doctor-report-dense-data.test.ts
+++ b/tests/integration/doctor-report-dense-data.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -32,20 +33,13 @@ const MODULES = {
nutrients: true,
} as const;
-const SECTIONS = {
- bp: false,
- weight: true,
- pulse: true,
- bmi: false,
- mood: false,
- compliance: false,
- sleep: false,
- glucose: true,
- cycle: false,
- labs: false,
- allergies: false,
- familyHistory: false,
-} as const;
+/** The dense path is about pulse and glucose; nothing else is in scope here. */
+const SELECTION = selectionFromLeaves([
+ "WEIGHT",
+ "PULSE",
+ "BLOOD_GLUCOSE",
+ "GLUCOSE_PANEL",
+]);
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
@@ -138,8 +132,7 @@ describe("doctor report — bounded dense measurement reads", () => {
await seedDenseBatch(0, 4);
const findManySpy = vi.spyOn(prisma.measurement, "findMany");
- const initial = await collectDoctorReportData(user.id, RANGE, {
- sections: { ...SECTIONS },
+ const initial = await collectDoctorReportData(user.id, RANGE, SELECTION, {
moduleMap: { ...MODULES },
});
@@ -168,8 +161,7 @@ describe("doctor report — bounded dense measurement reads", () => {
expect(rawRows.map((row) => row.type)).toEqual(["WEIGHT", "WEIGHT"]);
await seedDenseBatch(10, 300);
- const grown = await collectDoctorReportData(user.id, RANGE, {
- sections: { ...SECTIONS },
+ const grown = await collectDoctorReportData(user.id, RANGE, SELECTION, {
moduleMap: { ...MODULES },
});
diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts
index 629cfbafa..7529ad80a 100644
--- a/tests/integration/doctor-report-sections.test.ts
+++ b/tests/integration/doctor-report-sections.test.ts
@@ -16,6 +16,11 @@ process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
+import {
+ EMPTY_REPORT_SELECTION,
+ selectionFromLeaves,
+} from "@/lib/report-selection/selection";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -217,20 +222,22 @@ function pdfContainsText(bytes: Uint8Array, needle: string): boolean {
}
describe("doctor-report — per-section toggles", () => {
- it("drops mood data entirely when sections.mood = false", async () => {
+ it("drops mood data entirely when the mood leaf is not chosen", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-mood-off");
- const data = await collectDoctorReportData(userId, RANGE, {
- sections: {
- bp: true,
- weight: true,
- pulse: true,
- bmi: true,
- mood: false, // ← the privacy default
- compliance: true,
- sleep: true,
- },
- });
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ selectionFromLeaves([
+ "BLOOD_PRESSURE_SYS",
+ "BLOOD_PRESSURE_DIA",
+ "WEIGHT",
+ "PULSE",
+ "BODY_MASS_INDEX",
+ "MEDICATION_COMPLIANCE",
+ "SLEEP_DURATION",
+ ]),
+ );
// The aggregator must NOT return mood data when the toggle is off.
// The contract: the data never leaves the DB row, so the JSON
@@ -249,31 +256,27 @@ describe("doctor-report — per-section toggles", () => {
expect(pdfContainsText(pdfEn, "Mood")).toBe(false);
});
- it("includes mood data when sections.mood = true (opt-in)", async () => {
+ it("includes mood data when the mood leaf is chosen by name", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-mood-on");
- const data = await collectDoctorReportData(userId, RANGE, {
- sections: { mood: true },
- });
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ selectionFromLeaves(["MOOD"]),
+ );
expect(data.mood).not.toBeNull();
expect(data.mood?.count).toBe(1);
});
- it("strips BP / weight / pulse / compliance when their toggles are off", async () => {
+ it("strips BP / weight / pulse / compliance when their leaves are not chosen", async () => {
const userId = await seedUserWithEveryDataType("dr-sections-others-off");
- const data = await collectDoctorReportData(userId, RANGE, {
- sections: {
- bp: false,
- weight: false,
- pulse: false,
- bmi: false,
- mood: false,
- compliance: false,
- sleep: false,
- },
- });
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ selectionFromLeaves(["LAB_RESULTS"]),
+ );
expect(data.stats.BLOOD_PRESSURE_SYS).toBeUndefined();
expect(data.stats.BLOOD_PRESSURE_DIA).toBeUndefined();
@@ -284,19 +287,29 @@ describe("doctor-report — per-section toggles", () => {
expect(data.mood).toBeNull();
});
- it("applies documented defaults when sections is omitted (mood OFF, others ON)", async () => {
+ it("serves nothing at all for an empty selection", async () => {
+ // The case the release exists for. An account with data in every domain
+ // and a caller that named no leaf gets an empty record — not the
+ // "everything except mood and cycle" the old defaults produced.
const userId = await seedUserWithEveryDataType("dr-sections-defaults");
- const data = await collectDoctorReportData(userId, RANGE);
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ EMPTY_REPORT_SELECTION,
+ );
- // Privacy default per the maintainer — mood is opt-in, not opt-out.
expect(data.mood).toBeNull();
- // Every other section keeps its data.
- expect(data.stats.WEIGHT).toBeDefined();
- expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
- expect(data.stats.PULSE).toBeDefined();
- expect(data.bmi).not.toBeNull();
- expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
+ expect(data.stats).toEqual({});
+ expect(data.measurements).toEqual({});
+ expect(data.bmi).toBeNull();
+ expect(data.compliance).toEqual({});
+ expect(data.medications).toEqual([]);
+ expect(data.labResults).toBeNull();
+ expect(data.allergies).toBeNull();
+ expect(data.familyHistory).toBeNull();
+ expect(data.patient.username).toBeNull();
+ expect(data.patient.insurerName).toBeNull();
});
});
@@ -327,11 +340,12 @@ describe("doctor-report — module enable/disable gating", () => {
it("includes every module's data when all modules are enabled", async () => {
const userId = await seedUserWithEveryDataType("dr-modules-all-on");
- const data = await collectDoctorReportData(userId, RANGE, {
- // Opt mood in so the present-case asserts the full surface.
- sections: { mood: true },
- moduleMap: { ...ALL_ON },
- });
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ selectionFromLeaves(ALL_LEAF_IDS),
+ { moduleMap: { ...ALL_ON } },
+ );
expect(Object.keys(data.glucoseStats).length).toBeGreaterThan(0);
expect(data.measurements.ACTIVITY_STEPS).toBeDefined();
@@ -351,20 +365,24 @@ describe("doctor-report — module enable/disable gating", () => {
it("excludes a disabled module's section/resources from the payload", async () => {
const userId = await seedUserWithEveryDataType("dr-modules-off");
- const data = await collectDoctorReportData(userId, RANGE, {
- // The user opted mood in at the report level, but turned the modules off
- // — the module gate wins, so none of these surface.
- sections: { mood: true, sleep: true, labs: true },
- moduleMap: {
- ...ALL_ON,
- glucose: false,
- sleep: false,
- workouts: false,
- recovery: false,
- labs: false,
- mood: false,
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ // Every leaf chosen at the report level, but the modules are off — the
+ // module gate wins, so none of these surface.
+ selectionFromLeaves(ALL_LEAF_IDS),
+ {
+ moduleMap: {
+ ...ALL_ON,
+ glucose: false,
+ sleep: false,
+ workouts: false,
+ recovery: false,
+ labs: false,
+ mood: false,
+ },
},
- });
+ );
// Glucose panel collapses entirely (stats + raw series).
expect(data.glucoseStats).toEqual({});
@@ -394,9 +412,12 @@ describe("doctor-report — module enable/disable gating", () => {
it("gates recovery and workouts independently (recovery off, workouts on)", async () => {
const userId = await seedUserWithEveryDataType("dr-modules-recovery-off");
- const data = await collectDoctorReportData(userId, RANGE, {
- moduleMap: { ...ALL_ON, recovery: false },
- });
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ selectionFromLeaves(ALL_LEAF_IDS),
+ { moduleMap: { ...ALL_ON, recovery: false } },
+ );
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
// Recovery readiness scores gone…
@@ -447,7 +468,11 @@ describe("doctor-report — MedicationAdministration export cap", () => {
});
await prisma.medicationIntakeEvent.createMany({ data: rows });
- const data = await collectDoctorReportData(user.id, RANGE);
+ const data = await collectDoctorReportData(
+ user.id,
+ RANGE,
+ selectionFromLeaves(["MEDICATION_ADMINISTRATIONS"]),
+ );
expect(data.medicationAdministrations).toBeDefined();
expect(data.medicationAdministrations?.length).toBe(1000);
@@ -466,7 +491,11 @@ describe("doctor-report — MedicationAdministration export cap", () => {
it("leaves the set untouched (no truncation flag) below the cap", async () => {
const userId = await seedUserWithEveryDataType("dr-admin-no-cap");
- const data = await collectDoctorReportData(userId, RANGE);
+ const data = await collectDoctorReportData(
+ userId,
+ RANGE,
+ selectionFromLeaves(["MEDICATION_ADMINISTRATIONS"]),
+ );
expect(data.medicationAdministrations?.length).toBe(1);
expect(data.medicationAdministrationsTruncation ?? null).toBeNull();
diff --git a/tests/integration/medication-administrations.test.ts b/tests/integration/medication-administrations.test.ts
index f1b7ea5f7..e9452b922 100644
--- a/tests/integration/medication-administrations.test.ts
+++ b/tests/integration/medication-administrations.test.ts
@@ -17,6 +17,7 @@ process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -106,7 +107,15 @@ describe("medicationAdministrations aggregation (v1.9.0)", () => {
},
});
- const data = await collectDoctorReportData(user.id, RANGE);
+ const data = await collectDoctorReportData(
+ user.id,
+ RANGE,
+ selectionFromLeaves([
+ "MEDICATION_LIST",
+ "MEDICATION_ADMINISTRATIONS",
+ "MEDICATION_COMPLIANCE",
+ ]),
+ );
// Drug codes carried onto the medication concept source.
expect(data.medications[0].atcCode).toBe("A10BX10");
From 9e68ff525f35dc23168b4635823ae28e402ed680 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 18:57:45 +0200
Subject: [PATCH 06/17] feat(share): serve the record a link already implies,
and say when there is none
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.
The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.
The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.
Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.
The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
---
src/app/api/auth/me/report-selection/route.ts | 2 +-
.../health-record/__tests__/route.test.ts | 58 ++++
.../[token]/__tests__/report-download.test.ts | 278 ++++++++++++++++++
.../report-selection/selection-panel.tsx | 4 +-
.../__tests__/leaf-gating-sweep.test.ts | 78 +++++
.../__tests__/doctor-visit-summary.test.ts | 86 ++++++
src/lib/mcp/__tests__/resources.test.ts | 4 +-
src/lib/mcp/doctor-visit-summary.ts | 27 ++
src/lib/report-selection/profile-shape.ts | 43 +++
src/lib/report-selection/saved-profile.ts | 44 +--
10 files changed, 586 insertions(+), 38 deletions(-)
create mode 100644 src/app/c/[token]/__tests__/report-download.test.ts
create mode 100644 src/lib/mcp/__tests__/doctor-visit-summary.test.ts
create mode 100644 src/lib/report-selection/profile-shape.ts
diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts
index 06b9706b0..dd9c4b6a9 100644
--- a/src/app/api/auth/me/report-selection/route.ts
+++ b/src/app/api/auth/me/report-selection/route.ts
@@ -21,7 +21,7 @@ import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
-import { savedReportProfileSchema } from "@/lib/report-selection/saved-profile";
+import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
import { selectionFromRequest } from "@/lib/report-selection/selection";
export const dynamic = "force-dynamic";
diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts
index f8ab6cc74..e17570f34 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -47,6 +47,7 @@ vi.mock("@/lib/cycle/export-data", () => ({
currentPhase: "FOLLICULAR",
})),
}));
+vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -80,6 +81,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
import { prisma } from "@/lib/db";
import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
+import { decrypt } from "@/lib/crypto";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -723,3 +725,59 @@ describe("POST /api/export/health-record — what the route remembers", () => {
);
});
});
+
+/**
+ * The insurance number is its own leaf, and it is the most sensitive value on
+ * this path: encrypted at rest, and the one identifier a share link may never
+ * carry at all.
+ *
+ * Mutation check: drop the `selection.has("INSURANCE")` condition on the
+ * decrypt and "never decrypts the insurance number" goes red.
+ */
+describe("POST /api/export/health-record — the insurance leaf", () => {
+ beforeEach(() => {
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ username: "sample",
+ dateOfBirth: null,
+ gender: null,
+ heightCm: null,
+ glucoseUnit: null,
+ thresholdsJson: null,
+ fullName: null,
+ insurerName: "Sample Insurer",
+ insurerIkNumber: "123456789",
+ insuranceNumberEncrypted: Buffer.from("ciphertext"),
+ } as never);
+ });
+
+ it("never decrypts the insurance number when the leaf was not chosen", async () => {
+ const { POST } = await import("../route");
+ const res = await POST(
+ mkReq({ format: "fhir", selection: sel("WEIGHT", "PATIENT_IDENTITY") }),
+ );
+ expect(res.status).toBe(200);
+ // Not decrypted, so it never entered this process's memory — the stronger
+ // statement than "it was decrypted and then left out".
+ expect(decrypt).not.toHaveBeenCalled();
+ const bundle = (await res.json()) as {
+ entry: { resource: { resourceType: string } }[];
+ };
+ expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain(
+ "Coverage",
+ );
+ });
+
+ it("decrypts and carries it when the leaf was chosen by name", async () => {
+ const { POST } = await import("../route");
+ const res = await POST(
+ mkReq({
+ format: "fhir",
+ selection: sel("WEIGHT", "PATIENT_IDENTITY", "INSURANCE"),
+ }),
+ );
+ expect(res.status).toBe(200);
+ expect(decrypt).toHaveBeenCalled();
+ const text = JSON.stringify(await res.json());
+ expect(text).toContain("A123456789");
+ });
+});
diff --git a/src/app/c/[token]/__tests__/report-download.test.ts b/src/app/c/[token]/__tests__/report-download.test.ts
new file mode 100644
index 000000000..3bfde19f6
--- /dev/null
+++ b/src/app/c/[token]/__tests__/report-download.test.ts
@@ -0,0 +1,278 @@
+/**
+ * The two machine-format downloads on a share link.
+ *
+ * A practice opens the link, unlocks it, and saves the file into their system.
+ * That is what the removed FHIR toggle promised and never served. The gate has
+ * to be the document route's, step for step — the same live check, the same
+ * token-scoped unlock cookie, a rate limit before any aggregation, and the same
+ * blunt 404 for every miss class, so a probe cannot tell "no such link" from
+ * "revoked" from "documents-only".
+ *
+ * The load-bearing scope property: the download resolves the SAME frozen
+ * selection the page renders from, so it can never be wider than the page.
+ *
+ * Mutation checks:
+ * - remove the unlock-cookie check in `resolveShareReportDownload` →
+ * "refuses a protected link without the unlock cookie" goes red.
+ * - remove the rate-limit check → "refuses over the rate limit" goes red.
+ * - drop the `view.documentOnly` guard → "refuses a documents-only link"
+ * goes red.
+ * - change the bucket key to the document route's `share-documents:` →
+ * "uses its own rate bucket" goes red.
+ * - drop the `auditLog` call → "records an audit row per download" goes red.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("next/headers", () => ({ cookies: vi.fn() }));
+vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
+ resolveShareGateState: vi.fn(),
+ resolveShareToken: vi.fn(),
+}));
+vi.mock("@/lib/clinician-share/unlock-cookie", () => ({
+ unlockCookieName: (hash: string) => `hls_unlock_${hash}`,
+ verifyUnlockValue: vi.fn(),
+}));
+vi.mock("@/lib/clinician-share/share-view-data", () => ({
+ loadShareViewData: vi.fn(),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
+vi.mock("@/lib/i18n/server-translator", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return actual;
+});
+
+import { cookies } from "next/headers";
+import {
+ resolveShareGateState,
+ resolveShareToken,
+} from "@/lib/clinician-share/resolve-share-token";
+import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
+import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { auditLog } from "@/lib/auth/audit";
+import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import { selectionFromLeaves } from "@/lib/report-selection/selection";
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+
+const TOKEN = "hls_deadbeef";
+
+function report(): DoctorReportData {
+ return {
+ period: {
+ days: 30,
+ since: "2026-01-01",
+ start: "2026-01-01T00:00:00.000Z",
+ end: "2026-01-31T00:00:00.000Z",
+ },
+ patient: {
+ username: null,
+ dateOfBirth: null,
+ gender: null,
+ heightCm: null,
+ },
+ practiceName: null,
+ measurements: {},
+ stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
+ glucoseStats: {},
+ glucoseRanges: {},
+ glucoseClinical: computeGlucoseClinicalMetrics([], {
+ now: new Date("2026-01-31T00:00:00.000Z"),
+ }),
+ glucoseUnit: "mg/dL",
+ bmi: null,
+ compliance: {},
+ medications: [],
+ mood: null,
+ wellnessScores: null,
+ } as DoctorReportData;
+}
+
+function req(): Request {
+ return new Request(`http://localhost/c/${TOKEN}/fhir`);
+}
+
+const params = { params: Promise.resolve({ token: TOKEN }) };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(resolveShareGateState).mockResolvedValue({
+ tokenHash: "hash-1",
+ passphraseHash: "pass-hash",
+ } as never);
+ vi.mocked(cookies).mockResolvedValue({
+ get: () => ({ value: "unlock-value" }),
+ } as never);
+ vi.mocked(verifyUnlockValue).mockReturnValue(true);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ count: 1,
+ resetAt: new Date(),
+ } as never);
+ vi.mocked(resolveShareToken).mockResolvedValue({
+ shareLinkId: "link-1",
+ ownerUserId: "owner-1",
+ } as never);
+ vi.mocked(loadShareViewData).mockResolvedValue({
+ report: report(),
+ selection: selectionFromLeaves(["WEIGHT"]),
+ documents: [],
+ documentOnly: false,
+ } as never);
+});
+
+describe("GET /c/{token}/fhir", () => {
+ it("serves the link's frozen scope as a document Bundle", async () => {
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("application/fhir+json");
+ expect(res.headers.get("content-disposition")).toContain("attachment");
+ expect(res.headers.get("cache-control")).toBe("no-store");
+ const bundle = (await res.json()) as {
+ resourceType: string;
+ type: string;
+ };
+ expect(bundle.resourceType).toBe("Bundle");
+ expect(bundle.type).toBe("document");
+ });
+
+ it("carries no Coverage — the insurance leaf cannot reach this path", async () => {
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ const bundle = (await res.json()) as {
+ entry: { resource: { resourceType: string } }[];
+ };
+ expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain(
+ "Coverage",
+ );
+ });
+
+ it("refuses a protected link without the unlock cookie", async () => {
+ vi.mocked(verifyUnlockValue).mockReturnValue(false);
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ expect(res.status).toBe(404);
+ expect(loadShareViewData).not.toHaveBeenCalled();
+ });
+
+ it("refuses an unknown, revoked or expired token with the same flat 404", async () => {
+ vi.mocked(resolveShareGateState).mockResolvedValue(null as never);
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ expect(res.status).toBe(404);
+ expect(checkRateLimit).not.toHaveBeenCalled();
+ });
+
+ it("refuses a documents-only link even if a report came back with it", async () => {
+ // The flag is authoritative on its own. `loadShareViewData` returns a null
+ // report alongside it today, so the two conditions agree — this fixture
+ // separates them so the flag's own check is provably load-bearing rather
+ // than carried by its neighbour.
+ vi.mocked(loadShareViewData).mockResolvedValue({
+ report: report(),
+ selection: selectionFromLeaves(["WEIGHT"]),
+ documents: [],
+ documentOnly: true,
+ } as never);
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ expect(res.status).toBe(404);
+ });
+
+ it("refuses a link that produced no report", async () => {
+ vi.mocked(loadShareViewData).mockResolvedValue({
+ report: null,
+ selection: selectionFromLeaves([]),
+ documents: [],
+ documentOnly: false,
+ } as never);
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ expect(res.status).toBe(404);
+ });
+
+ it("refuses over the rate limit, before any aggregation", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: false,
+ count: 21,
+ resetAt: new Date(),
+ } as never);
+ const { GET } = await import("../fhir/route");
+ const res = await GET(req(), params);
+ expect(res.status).toBe(429);
+ expect(loadShareViewData).not.toHaveBeenCalled();
+ });
+
+ it("uses its own rate bucket, not the document route's", async () => {
+ const { GET } = await import("../fhir/route");
+ await GET(req(), params);
+ // Generating a bundle is far more expensive than serving a stored blob and
+ // must not share a budget with it.
+ expect(checkRateLimit).toHaveBeenCalledWith(
+ "report-download:hash-1",
+ 20,
+ 60 * 60 * 1000,
+ );
+ });
+
+ it("records an audit row per download, scoped to the owner", async () => {
+ const { GET } = await import("../fhir/route");
+ await GET(req(), params);
+ expect(auditLog).toHaveBeenCalledWith(
+ "share-link.report.download",
+ expect.objectContaining({
+ userId: "owner-1",
+ details: expect.objectContaining({
+ shareLinkId: "link-1",
+ format: "fhir",
+ leafCount: 1,
+ }),
+ }),
+ );
+ });
+});
+
+describe("GET /c/{token}/report.pdf", () => {
+ it("serves the link's frozen scope as a PDF", async () => {
+ const { GET } = await import("../report.pdf/route");
+ const res = await GET(
+ new Request(`http://localhost/c/${TOKEN}/report.pdf`),
+ params,
+ );
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ expect(res.headers.get("content-disposition")).toContain("attachment");
+ const buf = Buffer.from(await res.arrayBuffer());
+ expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-");
+ });
+
+ it("runs the same gate as the bundle beside it", async () => {
+ vi.mocked(verifyUnlockValue).mockReturnValue(false);
+ const { GET } = await import("../report.pdf/route");
+ const res = await GET(
+ new Request(`http://localhost/c/${TOKEN}/report.pdf`),
+ params,
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("records its own format in the audit row", async () => {
+ const { GET } = await import("../report.pdf/route");
+ await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params);
+ expect(auditLog).toHaveBeenCalledWith(
+ "share-link.report.download",
+ expect.objectContaining({
+ details: expect.objectContaining({ format: "pdf" }),
+ }),
+ );
+ });
+});
diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx
index 0c5da336a..7ef806750 100644
--- a/src/components/settings/report-selection/selection-panel.tsx
+++ b/src/components/settings/report-selection/selection-panel.tsx
@@ -54,7 +54,7 @@ import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import {
parseSavedProfile,
SAVED_PROFILE_FALLBACK,
-} from "@/lib/report-selection/saved-profile";
+} from "@/lib/report-selection/profile-shape";
import {
STANDARD_TEMPLATE_LABEL_KEY,
STANDARD_TEMPLATE_LEAVES,
@@ -82,7 +82,7 @@ export function HealthRecordExportPanel() {
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [practiceName, setPracticeName] = useState("");
- const [includeCharts, setIncludeCharts] = useState(
+ const [includeCharts, setIncludeCharts] = useState(
SAVED_PROFILE_FALLBACK.includeCharts,
);
const [selected, setSelected] = useState>(
diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts
index f5b37ae23..1bfab7e74 100644
--- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts
+++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts
@@ -287,6 +287,84 @@ describe("per-leaf gating sweep", () => {
}, 30_000);
});
+describe("the derived figures ride their own leaf", () => {
+ it("withholds the BMI figure while the weight series stays", async () => {
+ // The sweep alone cannot isolate this: BODY_MASS_INDEX also gates its own
+ // measurement rows, so the payload differs either way. The figure needs
+ // its own assertion, because weight rows stay readable for exactly this
+ // computation and it would be easy to leave the result ungated.
+ const withBmi = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["WEIGHT", "BODY_MASS_INDEX"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(withBmi.bmi).not.toBeNull();
+
+ const withoutBmi = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["WEIGHT"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(withoutBmi.bmi).toBeNull();
+ expect(withoutBmi.stats.WEIGHT).toBeDefined();
+ });
+
+ it("computes the BMI figure without carrying the weight series", async () => {
+ // The other half: choosing the figure and not the series still needs the
+ // weight rows read, and must not smuggle the series out with them.
+ const payload = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["BODY_MASS_INDEX"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(payload.bmi).not.toBeNull();
+ expect(payload.stats.WEIGHT).toBeUndefined();
+ expect(payload.measurements.WEIGHT).toBeUndefined();
+ });
+
+ it("withholds the GLP-1 block while its inputs stay readable", async () => {
+ const on = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["GLP1_THERAPY"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(on.glp1).not.toBeNull();
+
+ const off = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["MEDICATION_LIST", "MEDICATION_COMPLIANCE"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(off.glp1).toBeNull();
+ expect(off.medications.length).toBeGreaterThan(0);
+ });
+
+ it("withholds the glucose panel while the raw series stays", async () => {
+ const withPanel = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["BLOOD_GLUCOSE", "GLUCOSE_PANEL"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(Object.keys(withPanel.glucoseStats).length).toBeGreaterThan(0);
+
+ const seriesOnly = await collectDoctorReportData(
+ "u1",
+ RANGE,
+ selectionFromLeaves(["BLOOD_GLUCOSE"]),
+ { moduleMap: moduleMap() },
+ );
+ expect(seriesOnly.glucoseStats).toEqual({});
+ expect(seriesOnly.glucoseClinical.readingCount).toBe(0);
+ expect(seriesOnly.stats.BLOOD_GLUCOSE).toBeDefined();
+ });
+});
+
describe("fail-closed exclusion", () => {
it("excludes a measurement type the catalogue does not know", () => {
const gate = resolveReportGate(
diff --git a/src/lib/mcp/__tests__/doctor-visit-summary.test.ts b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts
new file mode 100644
index 000000000..01e81b3b4
--- /dev/null
+++ b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts
@@ -0,0 +1,86 @@
+/**
+ * The doctor-visit summariser, and what it says when there is nothing to say.
+ *
+ * The MCP surfaces cannot ask a human, so they replay the owner's saved report
+ * selection. An account that never saved one resolves to the empty selection
+ * and the aggregator returns an empty payload — which has to read as "nothing
+ * was recorded for this", not as a record whose every section happens to be
+ * missing. The `{ present: false, reason }` sentinel is the same one the other
+ * MCP reads use.
+ *
+ * Mutation checks:
+ * - make `present` unconditionally `true` again → "reports absence for an
+ * empty scope" goes red.
+ * - drop `data.bmi !== null` from the `hasContent` test → "counts a lone BMI
+ * figure as content" goes red.
+ */
+import { describe, it, expect } from "vitest";
+
+import { summariseForVisit } from "../doctor-visit-summary";
+import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+
+function payload(overrides: Partial = {}): DoctorReportData {
+ return {
+ period: {
+ days: 90,
+ since: "2026-01-01",
+ start: "2026-01-01T00:00:00.000Z",
+ end: "2026-04-01T00:00:00.000Z",
+ },
+ patient: {
+ username: null,
+ dateOfBirth: null,
+ gender: null,
+ heightCm: null,
+ },
+ practiceName: null,
+ measurements: {},
+ stats: {},
+ glucoseStats: {},
+ glucoseRanges: {},
+ glucoseClinical: computeGlucoseClinicalMetrics([], {
+ now: new Date("2026-04-01T00:00:00.000Z"),
+ }),
+ glucoseUnit: "mg/dL",
+ bmi: null,
+ compliance: {},
+ medications: [],
+ wellnessScores: null,
+ ...overrides,
+ } as DoctorReportData;
+}
+
+describe("summariseForVisit", () => {
+ it("reports absence for an empty scope, with the period it looked at", () => {
+ const summary = summariseForVisit(payload());
+ expect(summary.present).toBe(false);
+ expect(summary.reason).toBe("empty_report_selection");
+ expect(summary.period).toEqual({
+ days: 90,
+ start: "2026-01-01T00:00:00.000Z",
+ end: "2026-04-01T00:00:00.000Z",
+ });
+ // Nothing is zero-filled in its place.
+ expect(summary).not.toHaveProperty("vitals");
+ expect(summary).not.toHaveProperty("medications");
+ });
+
+ it("reports presence as soon as one metric survives the gate", () => {
+ const summary = summariseForVisit(
+ payload({
+ stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
+ }),
+ );
+ expect(summary.present).toBe(true);
+ expect(summary.vitals).toHaveLength(1);
+ });
+
+ it("counts a lone BMI figure as content", () => {
+ // The BMI figure can be the only thing chosen — the weight series stays
+ // withheld — and a summary carrying it is not an empty one.
+ const summary = summariseForVisit(payload({ bmi: 24.5 }));
+ expect(summary.present).toBe(true);
+ expect(summary.bmi).toBe(24.5);
+ });
+});
diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts
index c3da9bfbb..eae946c0a 100644
--- a/src/lib/mcp/__tests__/resources.test.ts
+++ b/src/lib/mcp/__tests__/resources.test.ts
@@ -256,7 +256,9 @@ describe("healthlog://report/doctor-visit", () => {
vi.mocked(collectDoctorReportData).mockResolvedValue({
period: { days: 90, since: "", start: "", end: "" },
patient: { dateOfBirth: "1990-01-01", gender: "female", heightCm: 170 },
- stats: {},
+ // One metric survived the gate, so the record is present; everything
+ // else is absent and must stay absent rather than being zero-filled.
+ stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
glucoseClinical: { stillLearning: true },
bmi: null,
compliance: {},
diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts
index 23f4e8467..53af77e41 100644
--- a/src/lib/mcp/doctor-visit-summary.ts
+++ b/src/lib/mcp/doctor-visit-summary.ts
@@ -112,6 +112,33 @@ export function summariseForVisit(
resolvedAt: e.resolvedAt,
}));
+ // Absence is honest here too. An account whose owner never saved a report
+ // selection resolves to the empty one on this surface, and an empty scope
+ // produces an empty payload — which must read as "nothing was recorded for
+ // this", not as a record with every section quietly missing. The
+ // `{ present: false, reason }` sentinel is the same one the other MCP reads
+ // use, so an assistant already knows how to read it.
+ const hasContent =
+ vitals.length > 0 ||
+ medications.length > 0 ||
+ compliance.length > 0 ||
+ labs.length > 0 ||
+ wellness.length > 0 ||
+ illness.length > 0 ||
+ data.mood != null ||
+ data.bmi != null;
+ if (!hasContent) {
+ return {
+ present: false,
+ reason: "empty_report_selection",
+ period: {
+ days: data.period.days,
+ start: data.period.start,
+ end: data.period.end,
+ },
+ };
+ }
+
return {
present: true,
period: {
diff --git a/src/lib/report-selection/profile-shape.ts b/src/lib/report-selection/profile-shape.ts
new file mode 100644
index 000000000..d531175c1
--- /dev/null
+++ b/src/lib/report-selection/profile-shape.ts
@@ -0,0 +1,43 @@
+/**
+ * The shape of the owner's saved report profile, without the read that
+ * fetches it.
+ *
+ * The export panel is a client component and needs to parse the blob that
+ * rides the `/api/auth/me` payload. Keeping the schema here means it can do
+ * that without pulling the Prisma client into the browser bundle — the same
+ * separation `saved-profile.ts` documents on the server side.
+ */
+import { z } from "zod/v4";
+
+import { reportSelectionSchema } from "./selection";
+
+/**
+ * The stored profile: the leaf list plus the render choices the panel
+ * restores. `format` / `rangeDays` / `includeCharts` are presentation, not
+ * scope — no gate reads them — so they ride alongside rather than inside the
+ * selection.
+ */
+export const savedReportProfileSchema = reportSelectionSchema.extend({
+ format: z.enum(["pdf", "fhir", "package"]),
+ rangeDays: z.number().int().min(1).max(365),
+ includeCharts: z.boolean(),
+});
+
+export type SavedReportProfile = z.infer;
+
+/**
+ * The render choices the panel starts from when the account has never saved a
+ * profile. Presentation only — this is deliberately NOT a scope fallback;
+ * there is no such thing here, because a scope nobody chose is the defect.
+ */
+export const SAVED_PROFILE_FALLBACK = {
+ format: "pdf",
+ rangeDays: 90,
+ includeCharts: true,
+} as const;
+
+/** Parse a stored profile blob, or `null` when there is none this build reads. */
+export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
+ const parsed = savedReportProfileSchema.safeParse(raw);
+ return parsed.success ? parsed.data : null;
+}
diff --git a/src/lib/report-selection/saved-profile.ts b/src/lib/report-selection/saved-profile.ts
index b0615a71e..987c2c02c 100644
--- a/src/lib/report-selection/saved-profile.ts
+++ b/src/lib/report-selection/saved-profile.ts
@@ -1,5 +1,5 @@
/**
- * The owner's saved report selection — "my selection".
+ * The owner's saved report selection — "my selection" — read from the row.
*
* Persisted on `User.reportSelectionJson` and written after a successful
* generation, the same way the practice name is. Two kinds of caller read it:
@@ -15,41 +15,23 @@
* account that has never saved one resolves to the EMPTY selection, so an
* assistant asking for a record the owner never scoped gets nothing — not a
* template, not a guess.
+ *
+ * The shape itself lives in `./profile-shape` so the client panel can parse
+ * the blob without pulling the Prisma client into the browser bundle.
*/
-import { z } from "zod/v4";
-
import { prisma } from "@/lib/db";
import {
EMPTY_REPORT_SELECTION,
- reportSelectionSchema,
selectionFromStoredBlob,
type ReportSelection,
} from "./selection";
-/**
- * The stored profile: the leaf list plus the render choices the panel
- * restores. `format` / `rangeDays` / `includeCharts` are presentation, not
- * scope — no gate reads them — so they ride alongside rather than inside the
- * selection.
- */
-export const savedReportProfileSchema = reportSelectionSchema.extend({
- format: z.enum(["pdf", "fhir", "package"]),
- rangeDays: z.number().int().min(1).max(365),
- includeCharts: z.boolean(),
-});
-
-export type SavedReportProfile = z.infer;
-
-/**
- * The render choices the panel starts from when the account has never saved a
- * profile. Presentation only — this is deliberately NOT a scope fallback;
- * there is no such thing here, because a scope nobody chose is the defect.
- */
-export const SAVED_PROFILE_FALLBACK = {
- format: "pdf",
- rangeDays: 90,
- includeCharts: true,
-} as const;
+export {
+ savedReportProfileSchema,
+ parseSavedProfile,
+ SAVED_PROFILE_FALLBACK,
+ type SavedReportProfile,
+} from "./profile-shape";
/**
* Resolve the owner's saved selection.
@@ -68,9 +50,3 @@ export async function resolveSavedSelection(
if (!row) return EMPTY_REPORT_SELECTION;
return selectionFromStoredBlob(row.reportSelectionJson);
}
-
-/** Parse a stored profile blob for the panel, or null when there is none. */
-export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
- const parsed = savedReportProfileSchema.safeParse(raw);
- return parsed.success ? parsed.data : null;
-}
From f12290532f1d3500ced018761106ff70a5fccb46 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 19:06:56 +0200
Subject: [PATCH 07/17] fix(sharing): let the owner replace a link the upgrade
closed, and count only what is shown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.
Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
---
messages/de.json | 3 +-
messages/en.json | 3 +-
messages/es.json | 3 +-
messages/fr.json | 3 +-
messages/it.json | 3 +-
messages/pl.json | 3 +-
.../__tests__/sharing-reselect.test.tsx | 82 +++++++++++++++++++
.../report-selection/report-scope-picker.tsx | 13 +--
.../settings/share-link-create-form.tsx | 16 +++-
src/components/settings/sharing-section.tsx | 65 +++++++++++++--
.../__tests__/panel-state.test.ts | 45 +++++++---
src/lib/report-selection/panel-state.ts | 18 ++--
12 files changed, 220 insertions(+), 37 deletions(-)
create mode 100644 src/components/settings/__tests__/sharing-reselect.test.tsx
diff --git a/messages/de.json b/messages/de.json
index fa6e0151e..334d94127 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Was dieser Link enthält",
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
"statusNeedsReselect": "Inhalt neu wählen",
- "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt."
+ "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.",
+ "reselectAction": "Ersatz anlegen"
},
"genderOther": "Divers",
"cycleTracking": {
diff --git a/messages/en.json b/messages/en.json
index 65b5cfbac..564f393b2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "What this link contains",
"scopeInsuranceExcluded": "Insurance details are never shared through a link.",
"statusNeedsReselect": "Pick contents again",
- "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain."
+ "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
+ "reselectAction": "Create a replacement"
},
"genderOther": "Other",
"cycleTracking": {
diff --git a/messages/es.json b/messages/es.json
index 7fe4c4e81..7f14c9594 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Qué contiene este enlace",
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
"statusNeedsReselect": "Elegir contenido de nuevo",
- "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido."
+ "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.",
+ "reselectAction": "Crear un reemplazo"
},
"genderOther": "Otro",
"cycleTracking": {
diff --git a/messages/fr.json b/messages/fr.json
index b2235e769..8aa90fa95 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Ce que contient ce lien",
"scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
"statusNeedsReselect": "Choisir à nouveau le contenu",
- "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu."
+ "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
+ "reselectAction": "Créer un remplacement"
},
"genderOther": "Autre",
"cycleTracking": {
diff --git a/messages/it.json b/messages/it.json
index 177128bdc..198f067a2 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Cosa contiene questo link",
"scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
"statusNeedsReselect": "Scegli di nuovo il contenuto",
- "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere."
+ "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
+ "reselectAction": "Crea un sostituto"
},
"genderOther": "Altro",
"cycleTracking": {
diff --git a/messages/pl.json b/messages/pl.json
index 613298b0d..563f4fc1f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5373,7 +5373,8 @@
"scopeTitle": "Co zawiera ten link",
"scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
"statusNeedsReselect": "Wybierz zawartość ponownie",
- "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość."
+ "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
+ "reselectAction": "Utwórz zamiennik"
},
"genderOther": "Inna",
"cycleTracking": {
diff --git a/src/components/settings/__tests__/sharing-reselect.test.tsx b/src/components/settings/__tests__/sharing-reselect.test.tsx
new file mode 100644
index 000000000..d4bd1da15
--- /dev/null
+++ b/src/components/settings/__tests__/sharing-reselect.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * The pick-again state on a link the selection upgrade closed.
+ *
+ * Migration 0273 revoked every live record share link minted before the
+ * selection model, because their frozen scope was "everything except mood and
+ * cycle" — a scope nobody chose. The owner has to see WHY the link stopped
+ * working and be able to replace it without retyping what they already
+ * decided, or the revocation reads as a fault.
+ *
+ * Mutation checks:
+ * - drop the `needsReselection` branch in `sharing-section.tsx` → "explains
+ * why the link closed" goes red.
+ * - drop `initialRangeDays` from `ShareLinkCreateForm`'s state seed →
+ * "carries the closed link's window into the replacement" goes red.
+ * - clamp nothing in `prefillFrom` → "clamps a lifetime past the cap" goes
+ * red.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { ShareLinkCreateForm } from "../share-link-create-form";
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQuery: () => ({ data: undefined, isLoading: false }),
+}));
+
+function render(node: React.ReactElement): string {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+function numberInputValue(html: string, id: string): string | null {
+ const input = html.match(new RegExp(`]*id="${id}"[^>]*>`))?.[0];
+ return input?.match(/value="([^"]*)"/)?.[1] ?? null;
+}
+
+describe("share-link create form — re-mint seeding", () => {
+ it("carries the closed link's window and lifetime into the replacement", () => {
+ const html = render(
+ ,
+ );
+ expect(numberInputValue(html, "share-range")).toBe("180");
+ expect(numberInputValue(html, "share-expiry")).toBe("45");
+ expect(html).toContain("Cardiology clinic");
+ });
+
+ it("falls back to the 30-day default when nothing is carried across", () => {
+ const html = render();
+ expect(numberInputValue(html, "share-range")).toBe("30");
+ expect(numberInputValue(html, "share-expiry")).toBe("30");
+ });
+
+ it("offers the scope picker, with the insurance leaf absent from it", () => {
+ const html = render();
+ expect(html).toContain('data-testid="report-scope-picker"');
+ expect(html).toContain('data-testid="report-group-row-identity"');
+ // The server refuses the leaf with a 422; a control that cannot be
+ // honoured has no business rendering, so the identity group is one leaf
+ // wide here and two wide in the export panel.
+ const row = html.match(
+ /data-testid="report-group-row-identity"[\s\S]*?<\/div>/,
+ )?.[0];
+ expect(row).toBeDefined();
+ expect(row).toContain(">1/1<");
+ // The fenced tier renders its leaves unconditionally; none is checked.
+ expect(html).toContain('data-testid="report-leaf-MOOD"');
+ expect(html).not.toContain('data-testid="report-leaf-INSURANCE"');
+ });
+
+ it("hides the scope picker entirely for a documents-only share", () => {
+ const html = render();
+ expect(html).not.toContain('data-testid="report-scope-picker"');
+ });
+});
diff --git a/src/components/settings/report-selection/report-scope-picker.tsx b/src/components/settings/report-selection/report-scope-picker.tsx
index c72622686..f6173c72e 100644
--- a/src/components/settings/report-selection/report-scope-picker.tsx
+++ b/src/components/settings/report-selection/report-scope-picker.tsx
@@ -74,9 +74,12 @@ export function ReportScopePicker({
onChange(toggleLeaf(selected, leaf));
};
- const handleGroup = (group: ReportGroupId) => {
+ const handleGroup = (
+ group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
+ ) => {
setRemembered(new Set(selected));
- onChange(toggleGroup(selected, group, remembered));
+ onChange(toggleGroup(selected, group, leaves, remembered));
};
return (
@@ -89,11 +92,11 @@ export function ReportScopePicker({
labelKey={group.labelKey}
leaves={group.leaves}
selected={selected}
- checkState={groupCheckState(group.id, selected)}
- count={groupCount(group.id, selected)}
+ checkState={groupCheckState(group.leaves, selected)}
+ count={groupCount(group.leaves, selected)}
open={openGroups.has(group.id)}
panelId={`${idPrefix}-${group.id}`}
- onToggleGroup={() => handleGroup(group.id)}
+ onToggleGroup={() => handleGroup(group.id, group.leaves)}
onToggleLeaf={handleLeaf}
onToggleOpen={() =>
setOpenGroups((prev) => {
diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx
index 69cdbab55..148648835 100644
--- a/src/components/settings/share-link-create-form.tsx
+++ b/src/components/settings/share-link-create-form.tsx
@@ -168,6 +168,8 @@ export function ShareLinkCreateForm({
documentOnly = false,
initialDocuments,
initialLabel,
+ initialRangeDays,
+ initialExpiryDays,
onCreated,
}: {
/**
@@ -181,6 +183,14 @@ export function ShareLinkCreateForm({
initialDocuments?: PickedDocument[];
/** Optional pre-filled label (e.g. the document title). */
initialLabel?: string;
+ /**
+ * Optional pre-filled window and lifetime, in days. The sharing list passes
+ * these when the owner re-mints a link the selection upgrade closed, so the
+ * only decision left in front of them is the one that link never carried:
+ * what it should contain.
+ */
+ initialRangeDays?: number;
+ initialExpiryDays?: number;
/** Fired after a link is minted, so an outer surface can react if needed. */
onCreated?: (created: ShareLinkCreated) => void;
}) {
@@ -188,8 +198,10 @@ export function ShareLinkCreateForm({
const queryClient = useQueryClient();
const [label, setLabel] = useState(initialLabel ?? "");
- const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
- const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
+ const [rangeDays, setRangeDays] = useState(initialRangeDays ?? DEFAULT_DAYS);
+ const [expiryDays, setExpiryDays] = useState(
+ initialExpiryDays ?? DEFAULT_DAYS,
+ );
const [selectedDocs, setSelectedDocs] = useState(
initialDocuments ?? [],
);
diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx
index 78c2669e2..76ab105e2 100644
--- a/src/components/settings/sharing-section.tsx
+++ b/src/components/settings/sharing-section.tsx
@@ -42,6 +42,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
+ MAX_DAYS,
ShareLinkCreateForm,
type ShareLinkSummary,
} from "@/components/settings/share-link-create-form";
@@ -61,12 +62,49 @@ export function SharingSection() {
);
}
+/**
+ * Carry a closed link's window and lifetime into a new create form.
+ *
+ * The window is measured from the frozen `rangeStart` to now (a rolling link
+ * has no end), the lifetime from when it was created to when it would have
+ * expired. Both are clamped into the form's own bounds so a very old link
+ * cannot seed an out-of-range value.
+ */
+function prefillFrom(link: ShareLinkSummary) {
+ const day = 86_400_000;
+ const rangeDays = Math.max(
+ 1,
+ Math.round((Date.now() - new Date(link.rangeStart).getTime()) / day),
+ );
+ const expiryDays = Math.min(
+ MAX_DAYS,
+ Math.max(
+ 1,
+ Math.round(
+ (new Date(link.expiresAt).getTime() -
+ new Date(link.createdAt).getTime()) /
+ day,
+ ),
+ ),
+ );
+ return { key: Date.now(), label: link.label, rangeDays, expiryDays };
+}
+
function ShareLinksCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [showRevoked, setShowRevoked] = useState(false);
+ // A re-mint carries the closed link's label, window and lifetime across, so
+ // the only thing left to decide is the scope it never had. Keyed so the form
+ // remounts and picks the values up as its initial state.
+ const [prefill, setPrefill] = useState<{
+ key: number;
+ label: string;
+ rangeDays: number;
+ expiryDays: number;
+ } | null>(null);
const { data: links } = useQuery({
queryKey: queryKeys.shareLinks(),
@@ -101,7 +139,12 @@ function ShareLinksCard() {
description={t("settings.sharing.createDescription")}
/>
-
+
@@ -248,12 +291,24 @@ function ShareLinksCard() {
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
-
- {t("settings.sharing.reselectReason")}
-
+
+ {t("settings.sharing.reselectReason")}
+
+
+
) : null}
diff --git a/src/lib/report-selection/__tests__/panel-state.test.ts b/src/lib/report-selection/__tests__/panel-state.test.ts
index 02456f10d..694d4c15d 100644
--- a/src/lib/report-selection/__tests__/panel-state.test.ts
+++ b/src/lib/report-selection/__tests__/panel-state.test.ts
@@ -30,24 +30,45 @@ const sensitive = REPORT_GROUPS.find((g) => g.id === "sensitive")!;
describe("scope picker state", () => {
it("derives none / mixed / all from the leaves alone", () => {
- expect(groupCheckState("vitals", new Set())).toBe("none");
- expect(groupCheckState("vitals", new Set([vitals.leaves[0]]))).toBe(
+ expect(groupCheckState(vitals.leaves, new Set())).toBe("none");
+ expect(groupCheckState(vitals.leaves, new Set([vitals.leaves[0]]))).toBe(
"mixed",
);
- expect(groupCheckState("vitals", new Set(vitals.leaves))).toBe("all");
+ expect(groupCheckState(vitals.leaves, new Set(vitals.leaves))).toBe("all");
});
- it("counts what is on against what exists", () => {
- const count = groupCount("vitals", new Set([vitals.leaves[0]]));
+ it("counts what is on against what the surface renders", () => {
+ const count = groupCount(vitals.leaves, new Set([vitals.leaves[0]]));
expect(count).toEqual({ on: 1, total: vitals.leaves.length });
});
+ it("counts against the RENDERED leaves, not the catalogue group", () => {
+ // The share-link form hides the insurance leaf, so the identity group is
+ // one leaf wide there. A count over the full group would tell the owner a
+ // control covers something they cannot see or reach.
+ const identity = REPORT_GROUPS.find((g) => g.id === "identity")!;
+ const rendered = identity.leaves.filter((l) => l !== "INSURANCE");
+ const selected = new Set(["PATIENT_IDENTITY"]);
+ expect(groupCount(rendered, selected)).toEqual({ on: 1, total: 1 });
+ expect(groupCheckState(rendered, selected)).toBe("all");
+ // And the whole group, for contrast.
+ expect(groupCount(identity.leaves, selected)).toEqual({ on: 1, total: 2 });
+ });
+
+ it("never adds a leaf the surface does not render", () => {
+ const identity = REPORT_GROUPS.find((g) => g.id === "identity")!;
+ const rendered = identity.leaves.filter((l) => l !== "INSURANCE");
+ const after = toggleGroup(new Set(), "identity", rendered);
+ expect(after.has("PATIENT_IDENTITY")).toBe(true);
+ expect(after.has("INSURANCE")).toBe(false);
+ });
+
it("turns a mixed group fully on, and a full group fully off", () => {
const partial = new Set([vitals.leaves[0]]);
- const on = toggleGroup(partial, "vitals");
- expect(groupCheckState("vitals", on)).toBe("all");
- const off = toggleGroup(on, "vitals");
- expect(groupCheckState("vitals", off)).toBe("none");
+ const on = toggleGroup(partial, "vitals", vitals.leaves);
+ expect(groupCheckState(vitals.leaves, on)).toBe("all");
+ const off = toggleGroup(on, "vitals", vitals.leaves);
+ expect(groupCheckState(vitals.leaves, off)).toBe("none");
});
it("restores the previous pattern when a group comes back on", () => {
@@ -55,14 +76,14 @@ describe("scope picker state", () => {
vitals.leaves[0],
vitals.leaves[1],
]);
- const off = toggleGroup(original, "vitals", original);
- const back = toggleGroup(off, "vitals", original);
+ const off = toggleGroup(original, "vitals", vitals.leaves, original);
+ const back = toggleGroup(off, "vitals", vitals.leaves, original);
expect([...back].sort()).toEqual([...original].sort());
});
it("lets no group control reach a fenced leaf", () => {
const before = new Set();
- const after = toggleGroup(before, "sensitive");
+ const after = toggleGroup(before, "sensitive", sensitive.leaves);
expect([...after]).toEqual([]);
for (const leaf of sensitive.leaves) {
expect(after.has(leaf)).toBe(false);
diff --git a/src/lib/report-selection/panel-state.ts b/src/lib/report-selection/panel-state.ts
index 2ef8a734e..afa11dbf9 100644
--- a/src/lib/report-selection/panel-state.ts
+++ b/src/lib/report-selection/panel-state.ts
@@ -24,11 +24,16 @@ export type PanelSelection = ReadonlySet;
/** A group checkbox is on, off, or partly on. `mixed` is never a click target. */
export type GroupCheckState = "all" | "none" | "mixed";
+/**
+ * `leaves` is the group's leaves AS THE SURFACE RENDERS THEM, which is not
+ * always the catalogue's: the share-link form hides the insurance leaf, and a
+ * count or a check state computed over the full group would then describe
+ * something the user cannot see or reach.
+ */
export function groupCheckState(
- group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
selected: PanelSelection,
): GroupCheckState {
- const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
if (leaves.length === 0) return "none";
const on = leaves.filter((leaf) => selected.has(leaf)).length;
if (on === 0) return "none";
@@ -36,12 +41,11 @@ export function groupCheckState(
return "mixed";
}
-/** How many of a group's leaves are chosen, and how many there are. */
+/** How many of the rendered leaves are chosen, and how many there are. */
export function groupCount(
- group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
selected: PanelSelection,
): { on: number; total: number } {
- const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
return {
on: leaves.filter((leaf) => selected.has(leaf)).length,
total: leaves.length,
@@ -75,12 +79,12 @@ export function toggleLeaf(
export function toggleGroup(
selected: PanelSelection,
group: ReportGroupId,
+ leaves: readonly ReportLeafId[],
remembered?: PanelSelection,
): Set {
if (group === SENSITIVE_GROUP_ID) return new Set(selected);
- const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
const next = new Set(selected);
- if (groupCheckState(group, selected) === "all") {
+ if (groupCheckState(leaves, selected) === "all") {
for (const leaf of leaves) next.delete(leaf);
return next;
}
From ffe99caccd2bb185f0f4f98107abbbec392a07df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 19:22:43 +0200
Subject: [PATCH 08/17] fix(fhir): stop advertising a credential no client can
obtain
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.
End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
---
docs/api/openapi.yaml | 9 ++--
e2e/settings-export.spec.ts | 50 +++++++++++++++++++
.../meta/capabilities/__tests__/route.test.ts | 8 +--
src/app/api/meta/capabilities/route.ts | 9 +++-
src/lib/openapi/routes/meta.ts | 8 +--
5 files changed, 72 insertions(+), 12 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 5f2d5e945..b69c0b124 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -15737,9 +15737,10 @@ components:
restBaseUrl:
type: string
description: Base path of the read-only FHIR R4 REST face.
- readScope:
- type: string
- description: Bearer scope a narrow token needs to read the FHIR face.
+ scopeMintable:
+ type: boolean
+ description: "Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the
+ scope, so the face is reachable by the owner's cookie session or a wildcard device token only."
resourceTypes:
type: array
items:
@@ -15760,7 +15761,7 @@ components:
- snomedRoute
- germanAtcDefaultLocales
- restBaseUrl
- - readScope
+ - scopeMintable
- resourceTypes
- operations
- searchParams
diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts
index e2de1e22b..dea803069 100644
--- a/e2e/settings-export.spec.ts
+++ b/e2e/settings-export.spec.ts
@@ -53,6 +53,56 @@ test.describe("Settings → Export & Import", () => {
});
});
+ test("opens the scope picker expanded on a first run, fenced tier and all", async ({
+ page,
+ }) => {
+ // The three panel states are the release's whole point, and the first one
+ // is the only place a template is applied. It has to be VISIBLE — a
+ // template behind a collapsed disclosure is opt-out wearing a hat — so the
+ // picker, its twelve group rows and the fenced tier all render without a
+ // click.
+ await page.goto("/settings/gesundheitsakte", {
+ waitUntil: "domcontentloaded",
+ });
+ await expect(page.getByTestId("report-scope-picker")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByTestId("report-group-row-vitals")).toBeVisible();
+ await expect(page.getByTestId("report-group-row-sensitive")).toHaveCount(0);
+ await expect(page.getByTestId("report-sensitive-tier")).toBeVisible();
+ // The fenced tier has no group control — there is no single click that
+ // turns on more than one sensitive leaf.
+ await expect(page.getByTestId("report-group-check-sensitive")).toHaveCount(
+ 0,
+ );
+ await expect(page.getByTestId("report-scope-summary")).toBeVisible();
+ });
+
+ test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/settings/gesundheitsakte", {
+ waitUntil: "domcontentloaded",
+ });
+ const row = page.getByTestId("report-group-row-vitals");
+ await expect(row).toBeVisible({ timeout: 10_000 });
+ const box = await row.boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.height).toBeGreaterThanOrEqual(44);
+ });
+
+ test("opens a group's leaf grid from its chevron", async ({ page }) => {
+ await page.goto("/settings/gesundheitsakte", {
+ waitUntil: "domcontentloaded",
+ });
+ const row = page.getByTestId("report-group-row-vitals");
+ await expect(row).toBeVisible({ timeout: 10_000 });
+ // Collapsed: the leaves are behind the disclosure, which is what makes a
+ // repeat run two interactions.
+ await expect(page.getByTestId("report-leaf-PULSE")).toHaveCount(0);
+ await row.getByRole("button", { expanded: false }).click();
+ await expect(page.getByTestId("report-leaf-PULSE")).toBeVisible();
+ });
+
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts
index 970ca750a..e4102b843 100644
--- a/src/app/api/meta/capabilities/__tests__/route.test.ts
+++ b/src/app/api/meta/capabilities/__tests__/route.test.ts
@@ -53,7 +53,6 @@ import {
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
- FHIR_READ_SCOPE,
FHIR_REST_RESOURCE_TYPES,
FHIR_EVERYTHING_OPERATION,
FHIR_SEARCH_PARAMS,
@@ -87,7 +86,7 @@ type CapabilitiesBody = {
snomedRoute: string;
germanAtcDefaultLocales: string[];
restBaseUrl: string;
- readScope: string;
+ scopeMintable: boolean;
resourceTypes: string[];
operations: string[];
searchParams: string[];
@@ -169,7 +168,10 @@ describe("GET /api/meta/capabilities — drift guards", () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.fhir.restBaseUrl).toBe("/api/fhir");
- expect(body.data.fhir.readScope).toBe(FHIR_READ_SCOPE);
+ // The face checks `fhir:read`, but nothing mints a token carrying it, so
+ // advertising it as an obtainable credential would be a claim the code
+ // cannot support.
+ expect(body.data.fhir.scopeMintable).toBe(false);
expect(body.data.fhir.resourceTypes).toEqual([...FHIR_REST_RESOURCE_TYPES]);
expect(body.data.fhir.operations).toEqual([FHIR_EVERYTHING_OPERATION]);
expect(body.data.fhir.searchParams).toEqual([...FHIR_SEARCH_PARAMS]);
diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts
index 8db19ac72..b9ce59410 100644
--- a/src/app/api/meta/capabilities/route.ts
+++ b/src/app/api/meta/capabilities/route.ts
@@ -41,7 +41,6 @@ import {
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
- FHIR_READ_SCOPE,
FHIR_REST_RESOURCE_TYPES,
FHIR_EVERYTHING_OPERATION,
FHIR_SEARCH_PARAMS,
@@ -111,7 +110,13 @@ export const GET = apiHandler(async () => {
// whole-record operation, the honoured search params and the narrow
// Bearer scope. All sourced from the canonical `rest.ts` constants.
restBaseUrl: "/api/fhir",
- readScope: FHIR_READ_SCOPE,
+ // The face checks a narrow `fhir:read` scope, and NO code path mints a
+ // token carrying it — every mint grants a wildcard, the medication
+ // ingest scope, or an MCP scope. So the face is reachable by the owner's
+ // cookie session or a wildcard device token, and by nothing else. It is
+ // reported that way rather than advertising a credential a client cannot
+ // obtain.
+ scopeMintable: false,
resourceTypes: FHIR_REST_RESOURCE_TYPES,
operations: [FHIR_EVERYTHING_OPERATION],
searchParams: FHIR_SEARCH_PARAMS,
diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts
index 7ce36fdef..8b9249711 100644
--- a/src/lib/openapi/routes/meta.ts
+++ b/src/lib/openapi/routes/meta.ts
@@ -70,9 +70,11 @@ const capabilitiesResponse = z
restBaseUrl: z
.string()
.describe("Base path of the read-only FHIR R4 REST face."),
- readScope: z
- .string()
- .describe("Bearer scope a narrow token needs to read the FHIR face."),
+ scopeMintable: z
+ .boolean()
+ .describe(
+ "Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the scope, so the face is reachable by the owner's cookie session or a wildcard device token only.",
+ ),
resourceTypes: z
.array(z.string())
.describe(
From 2f92c0577b035307d19bad29330a79e20381d558 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 19:28:31 +0200
Subject: [PATCH 09/17] chore(db): renumber the report-selection migration
after 0273 landed on main
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename prisma/migrations/{0273_report_selection => 0274_report_selection}/migration.sql (100%)
diff --git a/prisma/migrations/0273_report_selection/migration.sql b/prisma/migrations/0274_report_selection/migration.sql
similarity index 100%
rename from prisma/migrations/0273_report_selection/migration.sql
rename to prisma/migrations/0274_report_selection/migration.sql
From fcd3dd2d0789f8d5834d6b5033042199a1ef7b27 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 25 Jul 2026 19:57:31 +0200
Subject: [PATCH 10/17] fix(export): make each scope-picker mount addressable
on its own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Settings → Gesundheitsakte mounts the one scope picker twice: the export
panel at the top, the share-link create form under `#sharing`. Sharing the
component is deliberate, but it also meant every test id inside it resolved
to two elements on that page, so nothing in the picker could be addressed
without guessing which one you got.
The picker now takes a required `surface` and every id under it ends with
that surface — `report-group-row-vitals-export` and
`report-group-row-vitals-share` are two controls and read as two controls.
The summary line beside each picker follows the same rule. A third mount
picks a value and stays addressable by construction.
---
.../__tests__/report-selection-panel.test.tsx | 18 +++++++++++-------
.../__tests__/sharing-reselect.test.tsx | 12 ++++++------
.../settings/report-selection/group-row.tsx | 11 +++++++++--
.../settings/report-selection/leaf-grid.tsx | 9 +++++++--
.../report-selection/report-scope-picker.tsx | 17 +++++++++++++++--
.../report-selection/scope-summary.tsx | 10 ++++++++--
.../report-selection/selection-panel.tsx | 8 ++++++--
.../settings/report-selection/surface.ts | 16 ++++++++++++++++
.../settings/share-link-create-form.tsx | 3 ++-
9 files changed, 80 insertions(+), 24 deletions(-)
create mode 100644 src/components/settings/report-selection/surface.ts
diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx
index 830e1762d..3484046ff 100644
--- a/src/components/settings/__tests__/report-selection-panel.test.tsx
+++ b/src/components/settings/__tests__/report-selection-panel.test.tsx
@@ -95,7 +95,7 @@ function withSavedSelection(user: AuthUser, leaves: string[]): AuthUser {
*/
function leafChecked(html: string, leaf: string): boolean {
const row = html.match(
- new RegExp(`data-testid="report-leaf-${leaf}"[\\s\\S]*?`),
+ new RegExp(`data-testid="report-leaf-${leaf}-export"[\\s\\S]*?`),
)?.[0];
if (!row) return false;
return /data-state="checked"/.test(row);
@@ -104,7 +104,9 @@ function leafChecked(html: string, leaf: string): boolean {
/** The "n/total" chip a collapsed group row carries. */
function groupChip(html: string, group: string): string | null {
const row = html.match(
- new RegExp(`data-testid="report-group-row-${group}"[\\s\\S]*?`),
+ new RegExp(
+ `data-testid="report-group-row-${group}-export"[\\s\\S]*?`,
+ ),
)?.[0];
return row?.match(/data-slot="tag-chip"[^>]*>([^<]*))?.[1] ?? null;
}
@@ -145,7 +147,7 @@ describe(" — the first run", () => {
it("opens the picker expanded and names the template it applied", () => {
const html = renderPanel(buildUser(null));
expect(html).toContain('data-testid="health-record-included-data-panel"');
- expect(html).toContain('data-testid="report-scope-picker"');
+ expect(html).toContain('data-testid="report-scope-picker-export"');
expect(html).toContain("Standard doctor's report");
});
@@ -169,7 +171,7 @@ describe(" — the first run", () => {
it("leaves every fenced leaf unchecked and renders the tier", () => {
const html = renderPanel(buildUser(null));
- expect(html).toContain('data-testid="report-sensitive-tier"');
+ expect(html).toContain('data-testid="report-sensitive-tier-export"');
for (const leaf of [
"MOOD",
"CYCLE",
@@ -185,13 +187,15 @@ describe(" — the first run", () => {
it("gives the fenced tier no group checkbox", () => {
const html = renderPanel(buildUser(null));
- expect(html).toContain('data-testid="report-group-check-vitals"');
- expect(html).not.toContain('data-testid="report-group-check-sensitive"');
+ expect(html).toContain('data-testid="report-group-check-vitals-export"');
+ expect(html).not.toContain(
+ 'data-testid="report-group-check-sensitive-export"',
+ );
});
it("states the scope in one line", () => {
const html = renderPanel(buildUser(null));
- expect(html).toContain('data-testid="report-scope-summary"');
+ expect(html).toContain('data-testid="report-scope-summary-export"');
expect(html).toContain("14 entries from 7 areas");
});
});
diff --git a/src/components/settings/__tests__/sharing-reselect.test.tsx b/src/components/settings/__tests__/sharing-reselect.test.tsx
index d4bd1da15..aa1e206b4 100644
--- a/src/components/settings/__tests__/sharing-reselect.test.tsx
+++ b/src/components/settings/__tests__/sharing-reselect.test.tsx
@@ -60,23 +60,23 @@ describe("share-link create form — re-mint seeding", () => {
it("offers the scope picker, with the insurance leaf absent from it", () => {
const html = render();
- expect(html).toContain('data-testid="report-scope-picker"');
- expect(html).toContain('data-testid="report-group-row-identity"');
+ expect(html).toContain('data-testid="report-scope-picker-share"');
+ expect(html).toContain('data-testid="report-group-row-identity-share"');
// The server refuses the leaf with a 422; a control that cannot be
// honoured has no business rendering, so the identity group is one leaf
// wide here and two wide in the export panel.
const row = html.match(
- /data-testid="report-group-row-identity"[\s\S]*?<\/div>/,
+ /data-testid="report-group-row-identity-share"[\s\S]*?<\/div>/,
)?.[0];
expect(row).toBeDefined();
expect(row).toContain(">1/1<");
// The fenced tier renders its leaves unconditionally; none is checked.
- expect(html).toContain('data-testid="report-leaf-MOOD"');
- expect(html).not.toContain('data-testid="report-leaf-INSURANCE"');
+ expect(html).toContain('data-testid="report-leaf-MOOD-share"');
+ expect(html).not.toContain('data-testid="report-leaf-INSURANCE-share"');
});
it("hides the scope picker entirely for a documents-only share", () => {
const html = render();
- expect(html).not.toContain('data-testid="report-scope-picker"');
+ expect(html).not.toContain('data-testid="report-scope-picker-share"');
});
});
diff --git a/src/components/settings/report-selection/group-row.tsx b/src/components/settings/report-selection/group-row.tsx
index a20cf0df2..c1a68d902 100644
--- a/src/components/settings/report-selection/group-row.tsx
+++ b/src/components/settings/report-selection/group-row.tsx
@@ -25,9 +25,11 @@ import type {
import type { GroupCheckState } from "@/lib/report-selection/panel-state";
import { LeafGrid } from "./leaf-grid";
+import type { ReportScopeSurface } from "./surface";
export function GroupRow({
t,
+ surface,
id,
labelKey,
leaves,
@@ -41,6 +43,7 @@ export function GroupRow({
onToggleOpen,
}: {
t: (key: string, vars?: Record) => string;
+ surface: ReportScopeSurface;
id: ReportGroupId;
labelKey: string;
leaves: readonly ReportLeafId[];
@@ -55,7 +58,10 @@ export function GroupRow({
}) {
const label = t(labelKey);
return (
-
+
{label}
@@ -94,6 +100,7 @@ export function GroupRow({