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"[^>]*>([^<]*) + + , + ); +} + +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('