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");