mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
test(export): prove the scope holds at the artefact, not just at the switch
Five layers, because the defect had five places to hide. The catalogue guard checks what the compiler cannot state: every leaf resolves a label a human can read, every group resolves one, the fenced tier stays last, and the shipped template intersects the fence at nothing. The selection guard pins the three minting functions against each other. A stored blob this build cannot read resolves to the empty selection, never to a default set — that is what makes the revoked legacy links revocable rather than silently re-scoped. A request naming an id the server does not know is refused by name rather than dropped, because dropping it would narrow a scope the caller did express. The egress guard freezes the set of aggregator callers with the source of each one's selection written down. The required brand-typed parameter already stops a new path compiling without an answer; this stops one being added without a human writing the answer down, which is the moment a reviewer asks the question. The behavioural sweep aggregates every leaf in and out against a fixture that carries a row for each, and asserts the payload differs. It is parametrised over the catalogue, so a leaf added later is covered the day it is added. Its positive control asserts the all-on payload is genuinely populated first, so a "no difference" result means the gate is inert and not that the account is empty. Beside it: an unmapped measurement type is excluded rather than passed through, no table behind an unchosen leaf is queried at all, and an empty selection produces an empty record. The artefact layer runs the real route and reads what comes out — the FHIR bundle, the PDF text and the zip's contents — with a positive control beside every exclusion. Mutation-checked: making the gate admit everything reddens the sweep for every leaf; passing an unmapped type through reddens the fail-closed case; dropping the mood query's condition reddens the zero-read case; returning the BMI unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf to the template reddens the purity guard; removing the panel's seeding block reddens the first-run and repeat-run cases; dropping the insurance refusal reddens the share-link case; adding a caller outside the frozen list reddens the egress guard naming the file. The panel suite grew past the practice name it was written for and is renamed for what it now covers: the three states, the fenced tier having no group control, and the scope line naming its sensitive inclusions. Integration coverage moves to the leaf model, including the case this release exists for: an account with data in every domain and a caller that named nothing gets an empty record. The OpenAPI contract catches up with the routes — the required selection, the refused keys, the share summary's new fields, the moved `Patient/$everything`, the Coverage search and the five typed reads.
This commit is contained in:
1 parent
582db8a538
commit
f42934487d
70 files changed
+5646
-2459
No files matched your search
+282
-141
@@ -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:<userId>` 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:<userId>` 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:<userId>`, 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:<userId>`, 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
|
||||
|
||||
@@ -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<ReportLeafId, number>();
|
||||
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<string>(
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {},
|
||||
@@ -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<string, unknown>,
|
||||
): Promise<string[]> {
|
||||
async function bundleResourceTypes(selection: {
|
||||
v: number;
|
||||
leaves: string[];
|
||||
}): Promise<string[]> {
|
||||
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<string, unknown>,
|
||||
): Promise<string[]> {
|
||||
async function observationCodes(selection: {
|
||||
v: number;
|
||||
leaves: string[];
|
||||
}): Promise<string[]> {
|
||||
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" } }),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
+32
-24
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof import("@/lib/fhir/rest")>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<Response>]
|
||||
> = [
|
||||
["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<Response>]
|
||||
> = [
|
||||
["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");
|
||||
|
||||
@@ -78,6 +78,7 @@ beforeEach(() => {
|
||||
data: {} as never,
|
||||
identity: { insuranceNumber: null },
|
||||
germanAtc: false,
|
||||
records: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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("<ClinicianView>", () => {
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -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<typeof import("@/hooks/use-auth")>(
|
||||
"@/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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthRecordExportPanel />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function practiceInputValue(html: string): string | null {
|
||||
const input = html.match(/<input[^>]*id="hr-practice"[^>]*>/)?.[0];
|
||||
if (!input) return null;
|
||||
return input.match(/value="([^"]*)"/)?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("<HealthRecordExportPanel> — 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("");
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,7 @@ function buildUser(
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules,
|
||||
moduleAvailability: {},
|
||||
|
||||
@@ -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<typeof import("@/hooks/use-auth")>(
|
||||
"@/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]*?</label>`),
|
||||
)?.[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]*?</div>`),
|
||||
)?.[0];
|
||||
return row?.match(/data-slot="tag-chip"[^>]*>([^<]*)</)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function renderPanel(user: AuthUser | null): string {
|
||||
authSpy.mockReturnValue({ user });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthRecordExportPanel />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function practiceInputValue(html: string): string | null {
|
||||
const input = html.match(/<input[^>]*id="hr-practice"[^>]*>/)?.[0];
|
||||
if (!input) return null;
|
||||
return input.match(/value="([^"]*)"/)?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("<HealthRecordExportPanel> — 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("<HealthRecordExportPanel> — 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("<HealthRecordExportPanel> — 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('<option value="180" selected=""');
|
||||
});
|
||||
|
||||
it("falls back to the template when the saved blob cannot be read", () => {
|
||||
const user = {
|
||||
...buildUser(null),
|
||||
reportSelection: { bp: true, weight: true },
|
||||
} as AuthUser;
|
||||
const html = renderPanel(user);
|
||||
// A shape the server cannot read is not consent to anything, so the panel
|
||||
// shows the named template rather than a partial scope.
|
||||
expect(html).toContain("Standard doctor's report");
|
||||
expect(groupChip(html, "body")).toBe("2/12");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
* TanStack hooks are mocked; the QR (`qrcode`) only ever renders in an effect
|
||||
* after a create, so it never runs under `renderToStaticMarkup`.
|
||||
*/
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -110,6 +111,7 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
|
||||
label: " Blood panel 2026 ",
|
||||
rangeDays: 30,
|
||||
expiryDays: 30,
|
||||
leaves: ["WEIGHT", "LAB_RESULTS"] as ReportLeafId[],
|
||||
};
|
||||
|
||||
it("document-only: empty report scope and the launched doc always rides along", () => {
|
||||
@@ -123,7 +125,9 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
|
||||
// so a share link can no longer advertise a face that was never served.
|
||||
expect(payload).not.toHaveProperty("resourceTypes");
|
||||
expect(payload).not.toHaveProperty("allowFhirApi");
|
||||
// No report sections are ever sent for a document link.
|
||||
// No report scope is ever sent for a document link, whatever the picker
|
||||
// above happened to be showing.
|
||||
expect(payload).not.toHaveProperty("selection");
|
||||
expect(payload).not.toHaveProperty("sections");
|
||||
// The launched document is always in the created link (pre-attach fix).
|
||||
expect(payload.documentIds).toEqual(["doc-1"]);
|
||||
@@ -137,6 +141,11 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
|
||||
documentOnly: false,
|
||||
});
|
||||
expect(payload.rangeEnd).toBeNull();
|
||||
// A record share carries the leaves the owner chose, and nothing else.
|
||||
expect(payload.selection).toEqual({
|
||||
v: 2,
|
||||
leaves: ["WEIGHT", "LAB_RESULTS"],
|
||||
});
|
||||
expect(payload).not.toHaveProperty("resourceTypes");
|
||||
expect(payload).not.toHaveProperty("allowFhirApi");
|
||||
expect(payload).not.toHaveProperty("documentIds");
|
||||
|
||||
@@ -53,6 +53,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -51,8 +51,14 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import { parseSavedProfile } from "@/lib/report-selection/saved-profile";
|
||||
import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
|
||||
import {
|
||||
parseSavedProfile,
|
||||
SAVED_PROFILE_FALLBACK,
|
||||
} from "@/lib/report-selection/saved-profile";
|
||||
import {
|
||||
STANDARD_TEMPLATE_LABEL_KEY,
|
||||
STANDARD_TEMPLATE_LEAVES,
|
||||
} from "@/lib/report-selection/template";
|
||||
import { orderLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { ReportScopePicker } from "./report-scope-picker";
|
||||
@@ -68,13 +74,17 @@ export function HealthRecordExportPanel() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(90);
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
SAVED_PROFILE_FALLBACK.format,
|
||||
);
|
||||
const [days, setDays] = useState<number>(SAVED_PROFILE_FALLBACK.rangeDays);
|
||||
const [customRange, setCustomRange] = useState(false);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
const [includeCharts, setIncludeCharts] = useState(true);
|
||||
const [includeCharts, setIncludeCharts] = useState(
|
||||
SAVED_PROFILE_FALLBACK.includeCharts,
|
||||
);
|
||||
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
|
||||
() => new Set(STANDARD_TEMPLATE_LEAVES),
|
||||
);
|
||||
@@ -328,7 +338,7 @@ export function HealthRecordExportPanel() {
|
||||
data-testid="report-template-note"
|
||||
>
|
||||
{t("reportSelection.templateApplied", {
|
||||
template: t("reportSelection.templateStandard"),
|
||||
template: t(STANDARD_TEMPLATE_LABEL_KEY),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -37,6 +37,10 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
|
||||
import { collectDoctorReportData } from "../doctor-report-data";
|
||||
import { resolveModuleMap, MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import {
|
||||
EMPTY_REPORT_SELECTION,
|
||||
selectionFromLeaves,
|
||||
} from "@/lib/report-selection/selection";
|
||||
|
||||
const RANGE = {
|
||||
start: new Date("2026-01-01T00:00:00.000Z"),
|
||||
@@ -61,6 +65,7 @@ describe("collectDoctorReportData — doctorReport backstop", () => {
|
||||
collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
EMPTY_REPORT_SELECTION,
|
||||
// Injected rather than read, so the refusal is attributable to the
|
||||
// module state and not to a DB stub.
|
||||
{
|
||||
@@ -79,9 +84,9 @@ describe("collectDoctorReportData — doctorReport backstop", () => {
|
||||
moduleMap({ doctorReport: false }) as never,
|
||||
);
|
||||
|
||||
await expect(collectDoctorReportData("u1", RANGE)).rejects.toThrow(
|
||||
/doctorReport/,
|
||||
);
|
||||
await expect(
|
||||
collectDoctorReportData("u1", RANGE, EMPTY_REPORT_SELECTION),
|
||||
).rejects.toThrow(/doctorReport/);
|
||||
expect(resolveModuleMap).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
@@ -92,7 +97,7 @@ describe("collectDoctorReportData — doctorReport backstop", () => {
|
||||
// stub, never the backstop, or the "off" assertions above would pass
|
||||
// trivially for a function that always throws.
|
||||
await expect(
|
||||
collectDoctorReportData("u1", RANGE, {
|
||||
collectDoctorReportData("u1", RANGE, selectionFromLeaves(["WEIGHT"]), {
|
||||
moduleMap: moduleMap() as never,
|
||||
}),
|
||||
).rejects.toThrow("DB touched");
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
buildDoctorReportPdfDocument,
|
||||
renderDoctorReportPdfBytes,
|
||||
sanitiseForPdf,
|
||||
DOCTOR_REPORT_VITAL_TYPES,
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS,
|
||||
DOCTOR_REPORT_TYPE_UNIT_KEYS,
|
||||
DOCTOR_REPORT_VITAL_GROUPS,
|
||||
} from "../doctor-report-pdf-core";
|
||||
import { measurementTypeEnum } from "../validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import type { DoctorReportData } from "../doctor-report-data";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { getServerTranslator } from "../i18n/server-translator";
|
||||
@@ -197,7 +196,7 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
const text = await extractText(bytes);
|
||||
// The sleep label and the hours-converted value both appear in the table.
|
||||
// `num` renders one fixed fraction digit: 420 min → 7.0 h, 450 min → 7.5 h.
|
||||
expect(text).toContain("Sleep duration");
|
||||
expect(text).toContain("Sleep");
|
||||
expect(text).toContain("7.0 h");
|
||||
expect(text).toContain("7.5 h");
|
||||
// The raw minute value must NOT leak into the table.
|
||||
@@ -321,27 +320,30 @@ describe("doctor-report pagination", () => {
|
||||
// stale type map and silently dropped body composition while the browser
|
||||
// PDF rendered them. These tests pin the contract.
|
||||
describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
it("exposes body composition (TOTAL_BODY_WATER, BONE_MASS) as vital types", () => {
|
||||
expect(DOCTOR_REPORT_VITAL_TYPES).toContain("TOTAL_BODY_WATER");
|
||||
expect(DOCTOR_REPORT_VITAL_TYPES).toContain("BONE_MASS");
|
||||
it("puts body composition in the body group", () => {
|
||||
const body = DOCTOR_REPORT_VITAL_GROUPS.find(
|
||||
(g) => g.labelKey === "reportSelection.groupBody",
|
||||
)!;
|
||||
expect(body.types).toContain("TOTAL_BODY_WATER");
|
||||
expect(body.types).toContain("BONE_MASS");
|
||||
});
|
||||
|
||||
it("provides a label key + unit for every vital type", () => {
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
expect(DOCTOR_REPORT_TYPE_LABEL_KEYS[type]).toBeTruthy();
|
||||
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||||
expect(
|
||||
unit === null || (typeof unit === "string" && unit.length > 0),
|
||||
).toBe(true);
|
||||
it("provides a label key for every type it can render", () => {
|
||||
for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
|
||||
for (const type of group.types) {
|
||||
expect(MEASUREMENT_TYPE_LABEL_KEYS[type], type).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("vital types are a subset of the canonical measurement enum", () => {
|
||||
it("renders only types from the canonical measurement enum", () => {
|
||||
const enumSet = new Set<string>(measurementTypeEnum.options);
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe(
|
||||
true,
|
||||
);
|
||||
for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
|
||||
for (const type of group.types) {
|
||||
expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -645,19 +647,6 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
expect(text).not.toContain(t("doctorReport.cycleTitle"));
|
||||
});
|
||||
|
||||
it("clinical records/notes ignores a whitespace-only optional summary", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const text = await extractText(
|
||||
renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
aiSummary: " \n\t ",
|
||||
}),
|
||||
);
|
||||
expect(text).not.toContain(t("doctorReport.aiSummaryTitle"));
|
||||
});
|
||||
|
||||
it("footer is drawn once on every dynamically added page", async () => {
|
||||
const compliance: DoctorReportData["compliance"] = {};
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
DOCTOR_REPORT_VITAL_TYPES,
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS,
|
||||
getUnitForType,
|
||||
measurementTypeEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import {
|
||||
DOCTOR_REPORT_VITAL_GROUPS,
|
||||
DOCTOR_REPORT_TYPE_UNIT_KEYS,
|
||||
} from "@/lib/doctor-report-pdf-core";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
|
||||
// Single source of truth for which measurement types exist.
|
||||
// V3 audit "enum drift cousins": 7 module-level hardcoded arrays were
|
||||
@@ -136,170 +139,40 @@ describe("measurementTypeEnum coverage", () => {
|
||||
// differ by sex/age) which lands with the iOS-app clinical surface.
|
||||
// Updates to this set MUST be paired with a comment in
|
||||
// doctor-report-pdf-core.ts so the rationale stays discoverable.
|
||||
const PDF_VITAL_EXCLUSIONS = new Set([
|
||||
"BLOOD_GLUCOSE",
|
||||
"ACTIVITY_STEPS",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESTING_HEART_RATE",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
"FLIGHTS_CLIMBED",
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
"VO2_MAX",
|
||||
"BODY_TEMPERATURE",
|
||||
"FAT_FREE_MASS",
|
||||
"FAT_MASS",
|
||||
"MUSCLE_MASS",
|
||||
"SKIN_TEMPERATURE",
|
||||
"PULSE_WAVE_VELOCITY",
|
||||
"VASCULAR_AGE",
|
||||
"VISCERAL_FAT",
|
||||
// v1.4.25 W8d Apple Health additions held under the same v1.5 gate.
|
||||
// Audio exposure and time-in-daylight are lifestyle / environment
|
||||
// metrics; reference ranges + clinical layout land alongside the
|
||||
// first iOS-app sync, not the v1.4.25 PDF.
|
||||
"AUDIO_EXPOSURE_ENV",
|
||||
"AUDIO_EXPOSURE_HEADPHONE",
|
||||
"TIME_IN_DAYLIGHT",
|
||||
// v1.4.30 R-F T1.4 + T1.5 additions held under the same v1.5 gate.
|
||||
// Walking-steadiness pairs with a mobility chip; audio-exposure
|
||||
// events surface on the Insights audio sub-page. Neither belongs
|
||||
// in the clinical PDF until the iOS-app sync lands.
|
||||
"WALKING_STEADINESS",
|
||||
"AUDIO_EXPOSURE_EVENT",
|
||||
// v1.5.5 iOS-coord additions held under the same v1.5 PDF gate.
|
||||
// Respiratory rate, BMI, lean body mass, walking HR average, and
|
||||
// the two gait-percent metrics ride the same clinical-layout
|
||||
// deferral as the other v1.4.23+ additions; the doctor PDF picks
|
||||
// them up once the layout and reference ranges land.
|
||||
"RESPIRATORY_RATE",
|
||||
"BODY_MASS_INDEX",
|
||||
"LEAN_BODY_MASS",
|
||||
"WALKING_HEART_RATE_AVERAGE",
|
||||
"WALKING_ASYMMETRY",
|
||||
"WALKING_DOUBLE_SUPPORT",
|
||||
// v1.5.5 iOS-coord follow-up — raw-SI gait pair held under the
|
||||
// same clinical-layout deferral as the rest of the Mobility set.
|
||||
"WALKING_STEP_LENGTH",
|
||||
"WALKING_SPEED",
|
||||
// v1.10.0 — additive HealthKit signals (WX-A) held under the same
|
||||
// v1.5+ clinical-layout PDF gate as the rest of the Apple-Health
|
||||
// additions. Cardio recovery, wrist temperature, falls, six-minute
|
||||
// walk, the stair gait speeds, and the sleep-breathing index each
|
||||
// surface on their Insights sub-page; the doctor PDF picks them up
|
||||
// once a clinical layout + reference ranges land.
|
||||
"CARDIO_RECOVERY",
|
||||
"WRIST_TEMPERATURE",
|
||||
"FALL_COUNT",
|
||||
"SIX_MINUTE_WALK_DISTANCE",
|
||||
"STAIR_ASCENT_SPEED",
|
||||
"STAIR_DESCENT_SPEED",
|
||||
"BREATHING_DISTURBANCES",
|
||||
// v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are
|
||||
// discrete on-device notifications, not vital-sign readings. They are
|
||||
// surfaced on the Insights awareness timeline with their own regulatory
|
||||
// disclaimer, never in the clinical vitals PDF (their value is always 1
|
||||
// and they carry the device's verdict, not a measured quantity). See
|
||||
// doctor-report-pdf-core.ts for the matching exclusion rationale.
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
"HIGH_HEART_RATE_EVENT",
|
||||
"LOW_HEART_RATE_EVENT",
|
||||
"WALKING_STEADINESS_EVENT",
|
||||
"BREATHING_DISTURBANCE_EVENT",
|
||||
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
|
||||
// (Recovery / Stress / Strain) are 0–100 composites recomputed nightly,
|
||||
// not measured clinical vitals. They surface on their own Insights
|
||||
// cluster with a "descriptive, not clinical" disclaimer and never belong
|
||||
// in the clinical vitals PDF. See doctor-report-pdf-core.ts.
|
||||
"RECOVERY_SCORE",
|
||||
"STRESS_SCORE",
|
||||
"STRAIN_SCORE",
|
||||
// v1.11.0 — WHOOP-native score classes. Device-derived strain / recovery
|
||||
// / sleep-quality composites and a kJ energy total, not measured clinical
|
||||
// vitals. They surface on their own Insights cluster with a "descriptive,
|
||||
// not clinical" disclaimer and never belong in the clinical vitals PDF.
|
||||
// See doctor-report-pdf-core.ts for the matching exclusion rationale.
|
||||
"HRV_RMSSD",
|
||||
"DAY_STRAIN",
|
||||
"WORKOUT_STRAIN",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_EFFICIENCY",
|
||||
"SLEEP_CONSISTENCY",
|
||||
"SLEEP_NEED",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
// v1.12.8 — WHOOP cycle + sleep coverage completion. The daily-aggregate
|
||||
// heart-rate pair and the per-night disturbance count are device-derived
|
||||
// WHOOP signals, not measured clinical vitals; they surface on the
|
||||
// measurement list + Insights chart surfaces with a "descriptive, not
|
||||
// clinical" framing and never belong in the clinical vitals PDF. See
|
||||
// doctor-report-pdf-core.ts for the matching exclusion rationale.
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"SLEEP_DISTURBANCE_COUNT",
|
||||
// v1.17.1 — Polar Nightly Recharge + Training Load Pro components. ANS
|
||||
// charge and Cardio Load are device-derived recovery / strain composites,
|
||||
// not measured clinical vitals; they surface on their own Insights cluster
|
||||
// with a "descriptive, not clinical" framing and never belong in the
|
||||
// clinical vitals PDF. See doctor-report-pdf-core.ts.
|
||||
"ANS_CHARGE",
|
||||
"CARDIO_LOAD",
|
||||
// v1.17.1 — Oura coverage completion. The Sleep Score is a nightly
|
||||
// derived composite and the body-temperature deviation is a signed
|
||||
// baseline offset (illness / cycle / stress signal), not a measured
|
||||
// clinical vital — both surface on the Insights / measurement-list
|
||||
// surfaces and never belong in the clinical vitals PDF. See
|
||||
// doctor-report-pdf-core.ts for the matching exclusion rationale.
|
||||
"SLEEP_SCORE",
|
||||
"BODY_TEMPERATURE_DEVIATION",
|
||||
// v1.19.0 — Oura resilience. The resilience level is an ordinal-encoded
|
||||
// categorical band (limited=1 … exceptional=5), a derived recovery
|
||||
// composite, not a measured clinical vital — it surfaces on the
|
||||
// Insights / measurement-list surfaces and never belongs in the clinical
|
||||
// vitals PDF. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"RESILIENCE",
|
||||
// v1.25 — clinical-signals wave. The mental-health screener totals are
|
||||
// opt-in, derived, and deliberately excluded from any export/share/AI
|
||||
// surface by default (item answers are encrypted). The physical clinical
|
||||
// signals (grip strength, pain NRS, waist circumference + WHtR) surface on
|
||||
// their own measurement/insights surfaces; they join the doctor PDF once a
|
||||
// clinical layout + reference ranges land alongside the other v1.5+
|
||||
// additions. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
// v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
|
||||
// precedent: opt-in questionnaire derivations, excluded from the vitals
|
||||
// table; they ride the module-gated mental-health export section
|
||||
// instead. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
const expected = measurementTypeEnum.options.filter(
|
||||
(t) => !PDF_VITAL_EXCLUSIONS.has(t),
|
||||
it("doctor-report PDF renders every measurement type, grouped", () => {
|
||||
// The table used to be a fixed nine-type whitelist with a documented
|
||||
// exclusion list beside it, which meant a user could select resting heart
|
||||
// rate, get it in the FHIR bundle and the share view, and not find it in
|
||||
// the PDF. It is selection-driven now, so the assertion inverts: every
|
||||
// type in the enum appears in exactly one group, and the exclusion list is
|
||||
// gone with the whitelist that needed it.
|
||||
const rendered = DOCTOR_REPORT_VITAL_GROUPS.flatMap((g) => g.types);
|
||||
expect([...rendered].sort()).toEqual(
|
||||
[...measurementTypeEnum.options].sort(),
|
||||
);
|
||||
expect([...DOCTOR_REPORT_VITAL_TYPES].sort()).toEqual([...expected].sort());
|
||||
expect(new Set(rendered).size).toBe(rendered.length);
|
||||
});
|
||||
|
||||
it("doctor-report PDF has a label key for every renderable type", () => {
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
expect(
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS[type],
|
||||
`missing label key for ${type}`,
|
||||
).toBeTruthy();
|
||||
for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
|
||||
for (const type of group.types) {
|
||||
expect(
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[type],
|
||||
`missing label key for ${type}`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("doctor-report PDF has a unit key for every renderable type", () => {
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||||
expect(
|
||||
unit === null || (typeof unit === "string" && unit.length > 0),
|
||||
`missing unit for ${type}`,
|
||||
).toBe(true);
|
||||
it("doctor-report PDF resolves a unit or honestly none for every type", () => {
|
||||
// A type with no recorded unit prints none. An absent unit is absent, not
|
||||
// the word "unknown" beside a clinical number.
|
||||
for (const group of DOCTOR_REPORT_VITAL_GROUPS) {
|
||||
for (const type of group.types) {
|
||||
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type] ?? getUnitForType(type);
|
||||
expect(typeof unit === "string" || unit === null).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,9 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
vi.mock("@/lib/doctor-report-data", () => ({
|
||||
collectDoctorReportData: vi.fn(),
|
||||
}));
|
||||
// The real prefs parser is used so the "no section enabled ⇒ documents-only"
|
||||
// signal is exercised end to end (a stubbed parser would let the test lie
|
||||
// about which sectionsJson resolves to an empty report scope).
|
||||
// The real selection resolver is used so the "empty scope ⇒ documents-only"
|
||||
// signal is exercised end to end (a stubbed one would let the test lie about
|
||||
// which sectionsJson resolves to an empty report scope).
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
clinicianShareLinkDocument: { findMany: vi.fn() },
|
||||
@@ -28,7 +28,10 @@ vi.mock("@/lib/db", () => ({
|
||||
import { loadShareViewData } from "../share-view-data";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import {
|
||||
selectionToBlob,
|
||||
selectionFromLeaves,
|
||||
} from "@/lib/report-selection/selection";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ShareContext } from "../resolve-share-token";
|
||||
|
||||
@@ -44,7 +47,7 @@ function ctx(overrides: Partial<ShareContext> = {}): ShareContext {
|
||||
label: "Clinic",
|
||||
rangeStart: new Date("2026-01-01T00:00:00Z"),
|
||||
rangeEnd: new Date("2026-02-01T00:00:00Z"),
|
||||
sectionsJson: { mood: false },
|
||||
sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT", "PULSE"])),
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
...overrides,
|
||||
} as ShareContext;
|
||||
@@ -67,14 +70,16 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
|
||||
it("never requests an identifier / KVNR opt-in from the aggregator", async () => {
|
||||
await loadShareViewData(ctx());
|
||||
const opts = collect.mock.calls[0]![2] as Record<string, unknown>;
|
||||
// The only option ever passed is the frozen section toggles — no
|
||||
// includeIdentifiers, no kvnr, no decrypt flag. Default-OFF by absence.
|
||||
expect(opts).toBeDefined();
|
||||
expect(opts).not.toHaveProperty("includeIdentifiers");
|
||||
expect(opts).not.toHaveProperty("kvnr");
|
||||
expect(opts).not.toHaveProperty("insuranceNumber");
|
||||
expect(Object.keys(opts)).toEqual(["sections"]);
|
||||
// The third argument is the link's frozen selection and nothing else — no
|
||||
// includeIdentifiers, no kvnr, no decrypt flag, and no fourth argument at
|
||||
// all. The insurance leaf is refused at create, so it cannot be in there.
|
||||
const selection = collect.mock.calls[0]![2] as {
|
||||
has: (leaf: string) => boolean;
|
||||
leaves: string[];
|
||||
};
|
||||
expect(selection.leaves).toEqual(["PULSE", "WEIGHT"]);
|
||||
expect(selection.has("INSURANCE")).toBe(false);
|
||||
expect(collect.mock.calls[0]![3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a report payload carrying no insurance number", async () => {
|
||||
@@ -187,7 +192,7 @@ describe("loadShareViewData — documents-only share exposes no health data", ()
|
||||
]);
|
||||
|
||||
const { report, documentOnly, documents } = await loadShareViewData(
|
||||
ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS }),
|
||||
ctx({ sectionsJson: selectionToBlob(selectionFromLeaves([])) }),
|
||||
);
|
||||
|
||||
// The one guarantee: the aggregator is NEVER invoked — no health data is
|
||||
@@ -210,17 +215,31 @@ describe("loadShareViewData — documents-only share exposes no health data", ()
|
||||
]);
|
||||
});
|
||||
|
||||
it("still aggregates for a record share (defaults resolve to an enabled scope)", async () => {
|
||||
// `{}` / null sections resolve to the documented defaults (a full record
|
||||
// share), so the aggregator DOES run — the empty-scope short-circuit must
|
||||
// not swallow a normal record share.
|
||||
it("still aggregates for a record share that names leaves", async () => {
|
||||
// A link whose frozen scope names leaves DOES aggregate — the empty-scope
|
||||
// short-circuit must not swallow a normal record share.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({ sectionsJson: {}, documentOnly: false }),
|
||||
ctx({ documentOnly: false }),
|
||||
);
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
expect(documentOnly).toBe(false);
|
||||
expect(report).not.toBeNull();
|
||||
});
|
||||
|
||||
it("serves nothing for a legacy blob this build cannot read", async () => {
|
||||
// Every such link was revoked by migration 0273; this is the second lock
|
||||
// behind that. A shape the server cannot read is not consent to anything,
|
||||
// so it resolves to the empty selection rather than to a default scope.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({
|
||||
sectionsJson: { bp: true, weight: true, pulse: true },
|
||||
documentOnly: false,
|
||||
}),
|
||||
);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(report).toBeNull();
|
||||
expect(documentOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -244,7 +263,7 @@ describe("loadShareViewData — documentOnly column is authoritative", () => {
|
||||
// the exact future-leak the column closes: a new section defaulting on can
|
||||
// no longer widen an old documents-only link.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: true }, documentOnly: true }),
|
||||
ctx({ documentOnly: true }),
|
||||
);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(report).toBeNull();
|
||||
@@ -255,7 +274,10 @@ describe("loadShareViewData — documentOnly column is authoritative", () => {
|
||||
// A pre-column documents-only link reads `documentOnly:false` from the row
|
||||
// but still has every section off — the derived fallback keeps it closed.
|
||||
const { report, documentOnly } = await loadShareViewData(
|
||||
ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS, documentOnly: false }),
|
||||
ctx({
|
||||
sectionsJson: selectionToBlob(selectionFromLeaves([])),
|
||||
documentOnly: false,
|
||||
}),
|
||||
);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(report).toBeNull();
|
||||
@@ -264,92 +286,47 @@ describe("loadShareViewData — documentOnly column is authoritative", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.28.17 — the create schema accepts the GROUPED export sections shape
|
||||
* (`{ vitals: { bp, weight, … }, activity: { sleep }, medications: { … } }`)
|
||||
* and persists it raw. The clinician-view loader MUST fold that grouped shape
|
||||
* down to the flat toggles the aggregator consumes — reading it through the
|
||||
* flat doctor-report parser silently drops every grouped toggle and re-defaults
|
||||
* the section back ON, re-widening a scope the owner explicitly narrowed. This
|
||||
* pins that a section switched OFF in the grouped shape reaches the aggregator
|
||||
* as OFF, not as a defaults-on leak.
|
||||
* The frozen scope reaches the aggregator exactly as it was stored.
|
||||
*
|
||||
* The grouped/flat fold this block used to pin is gone with the shapes it
|
||||
* folded between: the link stores the leaf list the owner chose and the
|
||||
* aggregator reads that same list, so there is no second representation left
|
||||
* to disagree with the first.
|
||||
*/
|
||||
describe("loadShareViewData — grouped sections are folded, not silently defaulted", () => {
|
||||
describe("loadShareViewData — the frozen scope reaches the aggregator intact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
|
||||
findDocs.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("honours grouped OFF toggles instead of falling back to defaults-ON", async () => {
|
||||
// Owner froze a labs-focused share: vitals OFF, but the grouped keys live
|
||||
// under `vitals` where the flat parser cannot see them. Pre-fix, bp/weight/
|
||||
// pulse resolved to the defaults (ON) and leaked to the clinician.
|
||||
it("passes exactly the leaves the link froze", async () => {
|
||||
await loadShareViewData(
|
||||
ctx({
|
||||
sectionsJson: {
|
||||
vitals: { bp: false, weight: false, pulse: false },
|
||||
activity: { sleep: false },
|
||||
labs: true,
|
||||
},
|
||||
sectionsJson: selectionToBlob(
|
||||
selectionFromLeaves(["LAB_RESULTS", "BLOOD_PRESSURE_SYS"]),
|
||||
),
|
||||
documentOnly: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
const opts = collect.mock.calls[0]![2] as {
|
||||
sections: Record<string, boolean>;
|
||||
const selection = collect.mock.calls[0]![2] as {
|
||||
has: (leaf: string) => boolean;
|
||||
leaves: string[];
|
||||
};
|
||||
// The grouped OFF toggles must survive the fold.
|
||||
expect(opts.sections.bp).toBe(false);
|
||||
expect(opts.sections.weight).toBe(false);
|
||||
expect(opts.sections.pulse).toBe(false);
|
||||
expect(opts.sections.sleep).toBe(false);
|
||||
// A toggle the owner left ON stays ON.
|
||||
expect(opts.sections.labs).toBe(true);
|
||||
expect(selection.leaves).toEqual(["BLOOD_PRESSURE_SYS", "LAB_RESULTS"]);
|
||||
expect(selection.has("WEIGHT")).toBe(false);
|
||||
expect(selection.has("MOOD")).toBe(false);
|
||||
});
|
||||
|
||||
it("folds a grouped glucose=OFF toggle down to sections.glucose=false", async () => {
|
||||
// A diabetic owner withholds glucose from THIS share without disabling the
|
||||
// glucose module app-wide. The grouped `glucose` toggle must survive the
|
||||
// fold and reach the aggregator so no glucose panel is built.
|
||||
it("drops a leaf id this build no longer knows", async () => {
|
||||
await loadShareViewData(
|
||||
ctx({
|
||||
sectionsJson: {
|
||||
vitals: { bp: true },
|
||||
glucose: false,
|
||||
},
|
||||
sectionsJson: { v: 2, leaves: ["WEIGHT", "SOMETHING_RETIRED"] },
|
||||
documentOnly: false,
|
||||
}),
|
||||
);
|
||||
const opts = collect.mock.calls[0]![2] as {
|
||||
sections: Record<string, boolean>;
|
||||
};
|
||||
expect(opts.sections.glucose).toBe(false);
|
||||
// A share that leaves glucose unspecified keeps the default-ON behaviour.
|
||||
vi.clearAllMocks();
|
||||
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
|
||||
findDocs.mockResolvedValue([]);
|
||||
await loadShareViewData(
|
||||
ctx({ sectionsJson: { vitals: { bp: true } }, documentOnly: false }),
|
||||
);
|
||||
const opts2 = collect.mock.calls[0]![2] as {
|
||||
sections: Record<string, boolean>;
|
||||
};
|
||||
expect(opts2.sections.glucose).toBe(true);
|
||||
});
|
||||
|
||||
it("still resolves a flat legacy blob through the flat parser unchanged", async () => {
|
||||
// A flat shape has no grouped-only key, so it keeps the exact legacy path.
|
||||
await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: false, mood: true }, documentOnly: false }),
|
||||
);
|
||||
const opts = collect.mock.calls[0]![2] as {
|
||||
sections: Record<string, boolean>;
|
||||
};
|
||||
expect(opts.sections.bp).toBe(false);
|
||||
expect(opts.sections.mood).toBe(true);
|
||||
// Unspecified flat keys fall back to the documented defaults.
|
||||
expect(opts.sections.weight).toBe(true);
|
||||
const selection = collect.mock.calls[0]![2] as { leaves: string[] };
|
||||
expect(selection.leaves).toEqual(["WEIGHT"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,9 +357,7 @@ describe("clinician share — owner doctorReport module gate", () => {
|
||||
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
|
||||
findDocs.mockResolvedValue([]);
|
||||
|
||||
const res = await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
|
||||
);
|
||||
const res = await loadShareViewData(ctx({ documentOnly: false }));
|
||||
|
||||
expect(res.documentOnly).toBe(false);
|
||||
expect(res.report).not.toBeNull();
|
||||
@@ -393,9 +368,7 @@ describe("clinician share — owner doctorReport module gate", () => {
|
||||
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
|
||||
findDocs.mockResolvedValue([]);
|
||||
|
||||
const res = await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
|
||||
);
|
||||
const res = await loadShareViewData(ctx({ documentOnly: false }));
|
||||
|
||||
expect(res.documentOnly).toBe(true);
|
||||
expect(res.report).toBeNull();
|
||||
@@ -408,9 +381,7 @@ describe("clinician share — owner doctorReport module gate", () => {
|
||||
// The link is public; there is no viewer session. The owner id must come
|
||||
// from the frozen share context.
|
||||
findDocs.mockResolvedValue([]);
|
||||
await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
|
||||
);
|
||||
await loadShareViewData(ctx({ documentOnly: false }));
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("owner-1", "doctorReport");
|
||||
});
|
||||
|
||||
@@ -420,9 +391,7 @@ describe("clinician share — owner doctorReport module gate", () => {
|
||||
);
|
||||
findDocs.mockResolvedValue([]);
|
||||
|
||||
const res = await loadShareViewData(
|
||||
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
|
||||
);
|
||||
const res = await loadShareViewData(ctx({ documentOnly: false }));
|
||||
|
||||
expect(res.documentOnly).toBe(true);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
* (plus the frozen scope). It is NOT an `AuthContext`/session and can never
|
||||
* stand in for one.
|
||||
* - It never calls `getSession` / `authenticateBearer` and never sets a
|
||||
* cookie. A share token can authenticate exactly one surface — the public
|
||||
* clinician view (`/c/[token]`) and, when enabled, its scoped FHIR face —
|
||||
* and nothing else.
|
||||
* - The token arrives ONLY via the `X-HealthLog-Share` request header. It is
|
||||
* never read from `Authorization: Bearer`; presenting it on a normal authed
|
||||
* route does nothing (that route's `requireAuth` ignores this header).
|
||||
* cookie. A share token authenticates exactly one family of surfaces — the
|
||||
* public clinician view at `/c/[token]`, its document serve route, and its
|
||||
* two report downloads — and nothing else.
|
||||
* - The token arrives ONLY in the URL PATH of those surfaces. Nothing reads
|
||||
* it from a header, and presenting it as `Authorization: Bearer` on a
|
||||
* normal authed route does nothing: `requireAuth` refuses an `hls_` value,
|
||||
* which `share-token-reject-on-authed-route.test.ts` pins.
|
||||
*
|
||||
* Resolution is blunt on failure: an unknown / revoked / expired / malformed
|
||||
* token resolves to `null`, and the caller answers a flat 404 so a probe cannot
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* The anti-drift sweep: for every leaf that has data, aggregating with it and
|
||||
* without it must produce different payloads.
|
||||
*
|
||||
* This is the test that would have caught all eight dead switches on the day
|
||||
* they were added, and it is parametrised over the catalogue, so a leaf added
|
||||
* later is covered the day it is added rather than the day someone remembers.
|
||||
* It covers the PDF, the FHIR bundle, the share view and the MCP surface
|
||||
* transitively, because all four read this function's output.
|
||||
*
|
||||
* Two further properties sit beside it:
|
||||
*
|
||||
* - fail-closed: a measurement type the catalogue does not know is excluded,
|
||||
* not passed through. The old filter did the opposite, which made "we
|
||||
* forgot to map it" indistinguishable from "the user asked for it".
|
||||
* - zero read: a leaf that was not chosen is never queried. The payload has
|
||||
* to reflect "this was never fetched", not "this was fetched and dropped".
|
||||
*
|
||||
* Mutation checks:
|
||||
* - `resolveReportGate`: make `admits` return `true` unconditionally → the
|
||||
* sweep goes red listing every leaf, and the empty-selection assertions go
|
||||
* red too.
|
||||
* - `filterMeasurementKeys`: pass an unmapped type through instead of
|
||||
* skipping it → "excludes a measurement type the catalogue does not know"
|
||||
* goes red.
|
||||
* - `collect.ts`: drop the `gate.admits("MOOD")` condition on the mood query
|
||||
* → "never queries moodEntry for MOOD when it was not chosen" goes red.
|
||||
* - `collect.ts`: return `bmi` unconditionally → the sweep goes red naming
|
||||
* BODY_MASS_INDEX.
|
||||
* - `collect.ts`: drop the `identityOn` guard on `patient` → the sweep goes
|
||||
* red naming PATIENT_IDENTITY.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { calls, rows, prismaMock } = vi.hoisted(() => {
|
||||
const calls: string[] = [];
|
||||
const rows: Record<string, unknown[]> = {};
|
||||
const model = (name: string) => ({
|
||||
findMany: (args?: { where?: { type?: { notIn?: string[] } } }) => {
|
||||
calls.push(name);
|
||||
if (name === "measurement") {
|
||||
const blocked = new Set(args?.where?.type?.notIn ?? []);
|
||||
return Promise.resolve(
|
||||
(rows.measurement ?? []).filter(
|
||||
(r) => !blocked.has((r as { type: string }).type),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(rows[name] ?? []);
|
||||
},
|
||||
findUnique: () => {
|
||||
calls.push(name);
|
||||
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
calls,
|
||||
rows,
|
||||
prismaMock: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, prop: string) =>
|
||||
prop === "$queryRaw" ? () => Promise.resolve([]) : model(prop),
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
|
||||
vi.mock("@/lib/cycle/export-data", () => ({
|
||||
buildCycleExportSummary: vi.fn(async () => ({
|
||||
lastPeriodStart: "2026-02-01",
|
||||
recentCycles: [
|
||||
{ startDate: "2026-02-01", lengthDays: 28, periodLengthDays: 5 },
|
||||
],
|
||||
observedCycleCount: 1,
|
||||
averageCycleLengthDays: 28,
|
||||
cycleLengthVariabilityDays: null,
|
||||
averagePeriodLengthDays: 5,
|
||||
currentPhase: "FOLLICULAR",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { collectDoctorReportData } from "../collect";
|
||||
import { filterMeasurementKeys, resolveReportGate } from "../selection-gate";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
MEASUREMENT_LEAF_IDS,
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-types";
|
||||
|
||||
const RANGE = {
|
||||
start: new Date("2026-01-01T00:00:00.000Z"),
|
||||
end: new Date("2026-03-01T00:00:00.000Z"),
|
||||
days: 60,
|
||||
};
|
||||
|
||||
function moduleMap() {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const key of MODULE_KEYS) out[key] = true;
|
||||
return out as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row for every measurement type plus one row behind every structured
|
||||
* leaf, so no leaf's difference can be hidden by an empty account. The
|
||||
* positive control below asserts the all-on payload is genuinely populated,
|
||||
* which is what stops the sweep passing vacuously.
|
||||
*/
|
||||
function seed() {
|
||||
for (const key of Object.keys(rows)) delete rows[key];
|
||||
rows.measurement = MEASUREMENT_LEAF_IDS.map((type, i) => ({
|
||||
type,
|
||||
value: 10 + i,
|
||||
measuredAt: new Date("2026-02-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
deviceType: null,
|
||||
sleepStage: null,
|
||||
glucoseContext: type === "BLOOD_GLUCOSE" ? "FASTING" : null,
|
||||
}));
|
||||
rows.userUnique = [
|
||||
{
|
||||
username: "owner",
|
||||
dateOfBirth: new Date("1980-05-05T00:00:00.000Z"),
|
||||
gender: "OTHER",
|
||||
heightCm: 180,
|
||||
glucoseUnit: "MG_DL",
|
||||
thresholdsJson: null,
|
||||
timezone: "Europe/Berlin",
|
||||
sourcePriorityJson: null,
|
||||
fullName: "Owner Name",
|
||||
insurerName: "Sample Insurer",
|
||||
insurerIkNumber: "123456789",
|
||||
},
|
||||
];
|
||||
rows.medication = [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Sample drug",
|
||||
dose: "10 mg",
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
asNeeded: false,
|
||||
active: true,
|
||||
treatmentClass: "GLP1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date("2026-01-05T00:00:00.000Z"),
|
||||
doseValue: 1,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
noteEncrypted: null,
|
||||
},
|
||||
],
|
||||
intakeEvents: [
|
||||
{ takenAt: new Date("2026-02-10T08:00:00.000Z"), injectionSite: "ARM" },
|
||||
],
|
||||
},
|
||||
];
|
||||
rows.medicationIntakeEvent = [
|
||||
{
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date("2026-02-10T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-02-10T08:05:00.000Z"),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
attributionSource: null,
|
||||
injectionSite: "ARM",
|
||||
medication: {
|
||||
id: "m1",
|
||||
name: "Sample drug",
|
||||
dose: "10 mg",
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
deliveryForm: null,
|
||||
asNeeded: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
rows.moodEntry = [{ score: 4, tags: '["nausea"]' }];
|
||||
rows.labResult = [
|
||||
{
|
||||
panel: "Lipids",
|
||||
analyte: "LDL",
|
||||
value: 3.1,
|
||||
valueText: null,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
takenAt: new Date("2026-02-01T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
rows.illnessEpisode = [
|
||||
{
|
||||
label: "Sample condition",
|
||||
type: "ACUTE",
|
||||
lifecycle: "RESOLVED",
|
||||
onsetAt: new Date("2026-01-10T00:00:00.000Z"),
|
||||
resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
rows.allergy = [
|
||||
{
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
severity: "SEVERE",
|
||||
status: "ACTIVE",
|
||||
reactionEncrypted: null,
|
||||
},
|
||||
];
|
||||
rows.familyHistoryEntry = [
|
||||
{ relationship: "MOTHER", condition: "Hypertension", ageAtOnset: 50 },
|
||||
];
|
||||
}
|
||||
|
||||
function same(a: DoctorReportData, b: DoctorReportData): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
seed();
|
||||
});
|
||||
|
||||
describe("per-leaf gating sweep", () => {
|
||||
it("changes the payload for every leaf", async () => {
|
||||
const all = selectionFromLeaves(ALL_LEAF_IDS);
|
||||
const allPayload = await collectDoctorReportData("u1", RANGE, all, {
|
||||
moduleMap: moduleMap(),
|
||||
});
|
||||
|
||||
// Positive control: the fixture really does populate the payload, so a
|
||||
// "no difference" result below means the gate is inert, not that the
|
||||
// account is empty.
|
||||
expect(Object.keys(allPayload.stats).length).toBeGreaterThan(70);
|
||||
expect(allPayload.labResults).not.toBeNull();
|
||||
expect(allPayload.allergies).not.toBeNull();
|
||||
expect(allPayload.familyHistory).not.toBeNull();
|
||||
expect(allPayload.illnessEpisodes).not.toBeNull();
|
||||
expect(allPayload.mood).not.toBeNull();
|
||||
expect(allPayload.cycle).not.toBeNull();
|
||||
expect(allPayload.glp1).not.toBeNull();
|
||||
expect(allPayload.medications.length).toBeGreaterThan(0);
|
||||
expect(allPayload.medicationAdministrations?.length ?? 0).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(Object.keys(allPayload.compliance).length).toBeGreaterThan(0);
|
||||
expect(allPayload.patient.username).toBe("owner");
|
||||
expect(allPayload.patient.insurerName).toBe("Sample Insurer");
|
||||
expect(allPayload.bmi).not.toBeNull();
|
||||
expect(Object.keys(allPayload.glucoseStats).length).toBeGreaterThan(0);
|
||||
|
||||
const inert: ReportLeafId[] = [];
|
||||
for (const leaf of ALL_LEAF_IDS) {
|
||||
const without = selectionFromLeaves(
|
||||
ALL_LEAF_IDS.filter((l) => l !== leaf),
|
||||
);
|
||||
const payload = await collectDoctorReportData("u1", RANGE, without, {
|
||||
moduleMap: moduleMap(),
|
||||
});
|
||||
if (same(allPayload, payload)) inert.push(leaf);
|
||||
}
|
||||
|
||||
expect(
|
||||
inert,
|
||||
"withholding these leaves changed nothing that leaves the aggregator, " +
|
||||
"so a control for them would lie",
|
||||
).toEqual([]);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("fail-closed exclusion", () => {
|
||||
it("excludes a measurement type the catalogue does not know", () => {
|
||||
const gate = resolveReportGate(
|
||||
selectionFromLeaves(["WEIGHT"]),
|
||||
moduleMap(),
|
||||
);
|
||||
expect(
|
||||
filterMeasurementKeys({ WEIGHT: 1, SOMETHING_NEW: 2 }, gate),
|
||||
).toEqual({ WEIGHT: 1 });
|
||||
});
|
||||
|
||||
it("admits a mapped, selected type", () => {
|
||||
const gate = resolveReportGate(
|
||||
selectionFromLeaves(MEASUREMENT_LEAF_IDS),
|
||||
moduleMap(),
|
||||
);
|
||||
expect(filterMeasurementKeys({ WEIGHT: 1, PULSE: 2 }, gate)).toEqual({
|
||||
WEIGHT: 1,
|
||||
PULSE: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a selected type whose module is off", () => {
|
||||
const map = { ...(moduleMap() as unknown as Record<string, boolean>) };
|
||||
map.sleep = false;
|
||||
const gate = resolveReportGate(
|
||||
selectionFromLeaves(["SLEEP_DURATION", "WEIGHT"]),
|
||||
map as never,
|
||||
);
|
||||
expect(
|
||||
filterMeasurementKeys({ SLEEP_DURATION: 1, WEIGHT: 2 }, gate),
|
||||
).toEqual({ WEIGHT: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("zero read for unchosen leaves", () => {
|
||||
const gated: Array<[ReportLeafId, string]> = [
|
||||
["MOOD", "moodEntry"],
|
||||
["FAMILY_HISTORY", "familyHistoryEntry"],
|
||||
["ALLERGIES", "allergy"],
|
||||
["LAB_RESULTS", "labResult"],
|
||||
["ILLNESS_EPISODES", "illnessEpisode"],
|
||||
];
|
||||
|
||||
it.each(gated)(
|
||||
"never queries %s's table when it was not chosen",
|
||||
async (leaf, model) => {
|
||||
calls.length = 0;
|
||||
await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(ALL_LEAF_IDS.filter((l) => l !== leaf)),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(calls).not.toContain(model);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(gated)(
|
||||
"does query %s's table when it was chosen",
|
||||
async (_leaf, model) => {
|
||||
calls.length = 0;
|
||||
await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(ALL_LEAF_IDS),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(calls).toContain(model);
|
||||
},
|
||||
);
|
||||
|
||||
it("reads no medication row when no medication leaf was chosen", async () => {
|
||||
calls.length = 0;
|
||||
await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["WEIGHT"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(calls).not.toContain("medication");
|
||||
expect(calls).not.toContain("medicationIntakeEvent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the empty selection serves nothing", () => {
|
||||
it("returns an empty payload for an empty selection", async () => {
|
||||
const payload = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves([]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(payload.patient.username).toBeNull();
|
||||
expect(payload.patient.insurerName).toBeNull();
|
||||
expect(payload.labResults).toBeNull();
|
||||
expect(payload.allergies).toBeNull();
|
||||
expect(payload.familyHistory).toBeNull();
|
||||
expect(payload.illnessEpisodes).toBeNull();
|
||||
expect(payload.cycle).toBeNull();
|
||||
expect(payload.mood).toBeNull();
|
||||
expect(payload.glp1).toBeNull();
|
||||
expect(payload.bmi).toBeNull();
|
||||
expect(payload.wellnessScores).toBeNull();
|
||||
expect(payload.medications).toEqual([]);
|
||||
expect(payload.medicationAdministrations).toEqual([]);
|
||||
expect(payload.compliance).toEqual({});
|
||||
expect(payload.measurements).toEqual({});
|
||||
expect(payload.stats).toEqual({});
|
||||
expect(payload.glucoseStats).toEqual({});
|
||||
expect(payload.glucoseRanges).toEqual({});
|
||||
expect(payload.glucoseClinical.readingCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
lastReportPracticeName: null,
|
||||
reportSelection: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules: {},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,27 @@ function observationsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow an intra-document reference the way a receiver does: match it against
|
||||
* `Bundle.entry.fullUrl`. Every internal reference is a `urn:uuid`, so
|
||||
* asserting the literal string would only pin the UUID generator.
|
||||
*/
|
||||
function resolveRef(
|
||||
bundle: ReturnType<typeof buildFhirDocumentBundle>,
|
||||
reference: string | undefined,
|
||||
) {
|
||||
return bundle.entry.find((e) => e.fullUrl === reference)?.resource;
|
||||
}
|
||||
|
||||
/** `"Patient/patient-1"` for whatever entry the reference resolves to. */
|
||||
function refTarget(
|
||||
bundle: ReturnType<typeof buildFhirDocumentBundle>,
|
||||
reference: string | undefined,
|
||||
): string | undefined {
|
||||
const resource = resolveRef(bundle, reference);
|
||||
return resource ? `${resource.resourceType}/${resource.id}` : undefined;
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
@@ -167,7 +188,9 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const coverage = coverageOf(bundle);
|
||||
expect(coverage).toBeDefined();
|
||||
expect(coverage?.status).toBe("active");
|
||||
expect(coverage?.beneficiary.reference).toBe("Patient/patient-1");
|
||||
expect(refTarget(bundle, coverage?.beneficiary.reference)).toBe(
|
||||
"Patient/patient-1",
|
||||
);
|
||||
expect(coverage?.subscriberId).toBe("A123456780");
|
||||
// Payor references the contained Organization via a local #-ref.
|
||||
const payorRef = coverage?.payor?.[0].reference;
|
||||
@@ -268,7 +291,7 @@ describe("buildFhirDocumentBundle", () => {
|
||||
expect(coverageOf(bundle)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits a KVNR-only Coverage (no insurer) to align with the iOS exporter", () => {
|
||||
it("emits no Coverage for a bare KVNR — a Coverage needs a payor", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
@@ -283,15 +306,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const coverage = coverageOf(bundle);
|
||||
expect(coverage).toBeDefined();
|
||||
expect(coverage?.status).toBe("active");
|
||||
expect(coverage?.beneficiary.reference).toBe("Patient/patient-1");
|
||||
expect(coverage?.subscriberId).toBe("A123456780");
|
||||
// No insurer known → no contained payor Organization, no payor[].
|
||||
expect(coverage?.contained).toBeUndefined();
|
||||
expect(coverage?.payor).toBeUndefined();
|
||||
// KVNR still rides on Patient.identifier too.
|
||||
// R4 marks `Coverage.payor` 1..*: with no insurer name and no IKNR there
|
||||
// is nobody to name, and a Coverage that names no payor is not one.
|
||||
expect(coverageOf(bundle)).toBeUndefined();
|
||||
// Nothing is lost — the member id rides Patient.identifier.
|
||||
const patient = bundle.entry.find(
|
||||
(e) => e.resource.resourceType === "Patient",
|
||||
)?.resource as FhirPatient;
|
||||
@@ -656,7 +674,9 @@ describe("buildFhirDocumentBundle", () => {
|
||||
composition.resourceType === "Composition"
|
||||
? composition.section?.find((s) => s.title === "Medications")
|
||||
: undefined;
|
||||
const refs = medSection?.entry?.map((e) => e.reference) ?? [];
|
||||
const refs = (medSection?.entry ?? []).map((e) =>
|
||||
refTarget(bundle, e.reference),
|
||||
);
|
||||
expect(refs).toContain("MedicationAdministration/medadmin-1");
|
||||
});
|
||||
|
||||
@@ -1062,7 +1082,11 @@ describe("buildFhirDocumentBundle", () => {
|
||||
(c) => c.code === "HKQuantityTypeIdentifierFlightsClimbed",
|
||||
),
|
||||
);
|
||||
expect(audio?.valueQuantity?.code).toBe("dB[A]");
|
||||
// `dB[A]` is a readable label, not a UCUM expression — it stays on the
|
||||
// display `unit` and is never stamped as a coded UCUM symbol.
|
||||
expect(audio?.valueQuantity?.unit).toBe("dB[A]");
|
||||
expect(audio?.valueQuantity?.code).toBeUndefined();
|
||||
expect(audio?.valueQuantity?.system).toBeUndefined();
|
||||
expect(flights?.valueQuantity?.code).toBe("{flights}");
|
||||
});
|
||||
|
||||
@@ -1160,9 +1184,19 @@ describe("buildFhirDocumentBundle", () => {
|
||||
expect(report.code.coding?.[0].code).toBe("85353-1");
|
||||
expect(report.effectivePeriod?.start).toBe("2026-02-02T00:00:00.000Z");
|
||||
expect(report.effectivePeriod?.end).toBe("2026-05-03T12:00:00.000Z");
|
||||
const obsCount = observationsOf(bundle).length;
|
||||
expect(report.result).toHaveLength(obsCount);
|
||||
expect(obsCount).toBeGreaterThan(0);
|
||||
// A vital-signs panel reports vital signs, not labs / adherence / surveys.
|
||||
const vitals = observationsOf(bundle).filter((o) =>
|
||||
(o.category ?? []).some((c) =>
|
||||
(c.coding ?? []).some((coding) => coding.code === "vital-signs"),
|
||||
),
|
||||
);
|
||||
expect(vitals.length).toBeGreaterThan(0);
|
||||
expect(vitals.length).toBeLessThan(observationsOf(bundle).length);
|
||||
expect(report.result).toHaveLength(vitals.length);
|
||||
const referenced = (report.result ?? []).map((r) =>
|
||||
refTarget(bundle, r.reference),
|
||||
);
|
||||
expect(referenced).toEqual(vitals.map((o) => `Observation/${o.id}`));
|
||||
});
|
||||
|
||||
it("uses 'Vital signs' + 'Medications' Composition sections", () => {
|
||||
@@ -1250,7 +1284,9 @@ describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => {
|
||||
expect(enc.status).toBe("finished");
|
||||
expect(enc.period?.start).toBe("2026-04-01T00:00:00.000Z");
|
||||
expect(enc.period?.end).toBe("2026-04-10T00:00:00.000Z");
|
||||
expect(enc.reasonReference?.[0].reference).toBe(`Condition/${c.id}`);
|
||||
expect(refTarget(bundle, enc.reasonReference?.[0].reference)).toBe(
|
||||
`Condition/${c.id}`,
|
||||
);
|
||||
|
||||
const composition = bundle.entry[0].resource;
|
||||
const titles =
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* FHIR R4 conformance of the document Bundle and the emitters behind it.
|
||||
*
|
||||
* Every assertion here stands for a statement the export makes to a clinician's
|
||||
* system: that the document has an identity, that a reference it prints
|
||||
* resolves, that a gender it names is the one the person recorded, that a
|
||||
* period aggregate is not passed off as an instant reading, and that a coded
|
||||
* UCUM symbol is one a receiver can parse.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
|
||||
import { administrativeGender } from "@/lib/fhir/resources";
|
||||
import { isUcumCode, ucumQuantity } from "@/lib/fhir/ucum";
|
||||
import { allSignals } from "@/lib/signals/registry";
|
||||
import type {
|
||||
FhirBundle,
|
||||
FhirCoverage,
|
||||
FhirObservation,
|
||||
FhirResource,
|
||||
} from "@/lib/fhir/types";
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
return {
|
||||
period: {
|
||||
days: 90,
|
||||
since: "2026-02-02T00:00:00.000Z",
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
insurerIkNumber: "101234567",
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {
|
||||
WEIGHT: [{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }],
|
||||
BLOOD_PRESSURE_SYS: [
|
||||
{ value: 120, measuredAt: "2026-04-30T08:00:00.000Z" },
|
||||
],
|
||||
BLOOD_PRESSURE_DIA: [
|
||||
{ value: 78, measuredAt: "2026-04-30T08:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
stats: {},
|
||||
glucoseStats: {
|
||||
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
|
||||
},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], {
|
||||
now: FIXED_NOW,
|
||||
}),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
"Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 },
|
||||
},
|
||||
medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }],
|
||||
medicationAdministrations: [
|
||||
{
|
||||
medicationName: "Example Drug",
|
||||
effectiveAt: "2026-04-30T08:00:00.000Z",
|
||||
status: "completed",
|
||||
doseText: "5mg",
|
||||
dose: { value: 5, unit: "mg" },
|
||||
injectionSite: null,
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
deliveryForm: "ORAL",
|
||||
},
|
||||
],
|
||||
labResults: [
|
||||
{
|
||||
analyte: "HDL",
|
||||
unit: "mg/dL",
|
||||
value: 55,
|
||||
valueText: null,
|
||||
panel: null,
|
||||
takenAt: "2026-04-20T08:00:00.000Z",
|
||||
referenceLow: 40,
|
||||
referenceHigh: 80,
|
||||
},
|
||||
],
|
||||
illnessEpisodes: [
|
||||
{
|
||||
label: "Erkältung",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: "2026-04-01T00:00:00.000Z",
|
||||
resolvedAt: "2026-04-10T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
mood: { avg: 3.8, count: 20 },
|
||||
glp1: null,
|
||||
...overrides,
|
||||
} as DoctorReportData;
|
||||
}
|
||||
|
||||
function fullBundle(overrides?: Partial<DoctorReportData>): FhirBundle {
|
||||
return buildFhirDocumentBundle(
|
||||
makeData(overrides),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
{},
|
||||
{
|
||||
allergies: [
|
||||
{
|
||||
id: "a1",
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
status: "ACTIVE",
|
||||
severity: "SEVERE",
|
||||
reaction: "Rash",
|
||||
onsetAt: null,
|
||||
note: null,
|
||||
},
|
||||
] as never,
|
||||
familyHistory: [
|
||||
{
|
||||
id: "f1",
|
||||
relationship: "MOTHER",
|
||||
condition: "Hypertension",
|
||||
ageAtOnset: 50,
|
||||
note: null,
|
||||
},
|
||||
] as never,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Every `{ reference: … }` string anywhere in the serialised bundle. */
|
||||
function allReferences(value: unknown, out: string[] = []): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) allReferences(item, out);
|
||||
return out;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return out;
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (key === "reference" && typeof child === "string") out.push(child);
|
||||
else allReferences(child, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `text` string anywhere in the serialised bundle. */
|
||||
function allTexts(value: unknown, out: string[] = []): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) allTexts(item, out);
|
||||
return out;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return out;
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (key === "text" && typeof child === "string") out.push(child);
|
||||
else allTexts(child, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resourcesOf(bundle: FhirBundle): FhirResource[] {
|
||||
return bundle.entry.map((e) => e.resource);
|
||||
}
|
||||
|
||||
// ── Test 14 — document identity, entry identity, reference resolution ─────
|
||||
|
||||
describe("document Bundle — identity and resolvability", () => {
|
||||
it("carries a Bundle.identifier with a system and a UUIDv4 value", () => {
|
||||
const bundle = fullBundle();
|
||||
expect(bundle.identifier.system).toBe(
|
||||
"https://healthlog.dev/fhir/bundle-id",
|
||||
);
|
||||
expect(bundle.identifier.value).toMatch(UUID_V4);
|
||||
});
|
||||
|
||||
it("mints a fresh document identifier per build", () => {
|
||||
expect(fullBundle().identifier.value).not.toBe(
|
||||
fullBundle().identifier.value,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives every entry a distinct urn:uuid fullUrl over a UUIDv4", () => {
|
||||
const bundle = fullBundle();
|
||||
expect(bundle.entry.length).toBeGreaterThan(5);
|
||||
for (const entry of bundle.entry) {
|
||||
expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true);
|
||||
expect(entry.fullUrl.slice("urn:uuid:".length)).toMatch(UUID_V4);
|
||||
}
|
||||
const urls = new Set(bundle.entry.map((e) => e.fullUrl));
|
||||
expect(urls.size).toBe(bundle.entry.length);
|
||||
});
|
||||
|
||||
it("keeps the human-readable resource ids", () => {
|
||||
const ids = resourcesOf(fullBundle()).map((r) => r.id);
|
||||
expect(ids).toContain("patient-1");
|
||||
expect(ids).toContain("composition-1");
|
||||
expect(ids).toContain("obs-1");
|
||||
});
|
||||
|
||||
it("resolves every reference in the bundle against an entry fullUrl", () => {
|
||||
const bundle = fullBundle();
|
||||
const urls = new Set(bundle.entry.map((e) => e.fullUrl));
|
||||
const containedIds = new Set(
|
||||
resourcesOf(bundle).flatMap((r) =>
|
||||
"contained" in r && Array.isArray(r.contained)
|
||||
? r.contained.map((c) => `#${c.id}`)
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const references = allReferences(bundle);
|
||||
expect(references.length).toBeGreaterThan(5);
|
||||
for (const reference of references) {
|
||||
const resolvable = reference.startsWith("#")
|
||||
? containedIds.has(reference)
|
||||
: urls.has(reference);
|
||||
expect(resolvable, `unresolvable reference: ${reference}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the Device the Composition names as its author", () => {
|
||||
const bundle = fullBundle();
|
||||
const composition = bundle.entry[0].resource;
|
||||
if (composition.resourceType !== "Composition") {
|
||||
throw new Error("expected a leading Composition");
|
||||
}
|
||||
const authorRef = composition.author[0].reference;
|
||||
const author = bundle.entry.find((e) => e.fullUrl === authorRef)?.resource;
|
||||
expect(author?.resourceType).toBe("Device");
|
||||
if (author?.resourceType !== "Device") throw new Error("not a Device");
|
||||
expect(author.manufacturer).toBe("HealthLog");
|
||||
expect(author.deviceName?.[0].name).toBe("HealthLog");
|
||||
expect(author.version?.[0].value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("emits no Coverage without a payor", () => {
|
||||
for (const bundle of [
|
||||
fullBundle(),
|
||||
fullBundle({
|
||||
patient: {
|
||||
username: "u",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
for (const resource of resourcesOf(bundle)) {
|
||||
if (resource.resourceType !== "Coverage") continue;
|
||||
const coverage = resource as FhirCoverage;
|
||||
expect(coverage.payor.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("emits no empty-string text anywhere in the serialised bundle", () => {
|
||||
const bundle = fullBundle({
|
||||
// A qualitative lab row with neither a number nor a result term.
|
||||
labResults: [
|
||||
{
|
||||
analyte: "Borrelia IgG",
|
||||
unit: "",
|
||||
value: null,
|
||||
valueText: " ",
|
||||
panel: null,
|
||||
takenAt: "2026-04-20T08:00:00.000Z",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
},
|
||||
] as never,
|
||||
});
|
||||
for (const text of allTexts(bundle)) {
|
||||
expect(text.trim().length, "empty text in the bundle").toBeGreaterThan(0);
|
||||
}
|
||||
// …and the value-less row produced no Observation at all.
|
||||
const analytes = resourcesOf(bundle)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation")
|
||||
.map((o) => o.code.text);
|
||||
expect(analytes).not.toContain("Borrelia IgG");
|
||||
});
|
||||
|
||||
it("reaches Coverage, the Encounters and the DiagnosticReport from the Composition", () => {
|
||||
const bundle = fullBundle();
|
||||
const composition = bundle.entry[0].resource;
|
||||
if (composition.resourceType !== "Composition") {
|
||||
throw new Error("expected a leading Composition");
|
||||
}
|
||||
const reachable = new Set(
|
||||
(composition.section ?? []).flatMap((s) =>
|
||||
(s.entry ?? []).map((e) => e.reference),
|
||||
),
|
||||
);
|
||||
const reachableTypes = new Set(
|
||||
bundle.entry
|
||||
.filter((e) => reachable.has(e.fullUrl))
|
||||
.map((e) => e.resource.resourceType),
|
||||
);
|
||||
expect(reachableTypes.has("Coverage")).toBe(true);
|
||||
expect(reachableTypes.has("Encounter")).toBe(true);
|
||||
expect(reachableTypes.has("DiagnosticReport")).toBe(true);
|
||||
// …and the subject is NOT restated as a section entry.
|
||||
expect(reachableTypes.has("Patient")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 15 — administrative gender ──────────────────────────────────────
|
||||
|
||||
describe("Patient.gender", () => {
|
||||
it("maps the recorded value and omits everything else", () => {
|
||||
expect(administrativeGender("MALE")).toBe("male");
|
||||
expect(administrativeGender("FEMALE")).toBe("female");
|
||||
expect(administrativeGender("OTHER")).toBe("other");
|
||||
expect(administrativeGender(null)).toBeNull();
|
||||
expect(administrativeGender(undefined)).toBeNull();
|
||||
expect(administrativeGender("")).toBeNull();
|
||||
expect(administrativeGender("DIVERS")).toBeNull();
|
||||
});
|
||||
|
||||
it("never states a gender the person did not record", () => {
|
||||
const cases: Array<[string | null, string | undefined]> = [
|
||||
["MALE", "male"],
|
||||
["FEMALE", "female"],
|
||||
["OTHER", "other"],
|
||||
[null, undefined],
|
||||
["nonbinary", undefined],
|
||||
];
|
||||
for (const [stored, expected] of cases) {
|
||||
const bundle = fullBundle({
|
||||
patient: {
|
||||
username: "u",
|
||||
dateOfBirth: null,
|
||||
gender: stored,
|
||||
heightCm: null,
|
||||
},
|
||||
});
|
||||
const patient = resourcesOf(bundle).find(
|
||||
(r) => r.resourceType === "Patient",
|
||||
);
|
||||
if (patient?.resourceType !== "Patient") throw new Error("no Patient");
|
||||
expect(patient.gender, `stored ${String(stored)}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("never emits `unknown` — an unrecorded field is absent, not asked-and-unknown", () => {
|
||||
for (const stored of [null, "OTHER", "nope"]) {
|
||||
expect(administrativeGender(stored)).not.toBe("unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 16 — period aggregates carry effectivePeriod ────────────────────
|
||||
|
||||
describe("Observation.effective[x]", () => {
|
||||
const PERIOD_DERIVED = [
|
||||
"Body mass index (BMI) [Ratio]",
|
||||
"Fasting glucose [Mass/volume] in Serum or Plasma",
|
||||
"Mood (average over period)",
|
||||
"Medication adherence — Example Drug",
|
||||
];
|
||||
|
||||
it("states a window for a value derived over the window, not an instant", () => {
|
||||
const bundle = fullBundle();
|
||||
const observations = resourcesOf(bundle).filter(
|
||||
(r): r is FhirObservation => r.resourceType === "Observation",
|
||||
);
|
||||
for (const text of PERIOD_DERIVED) {
|
||||
const observation = observations.find((o) => o.code.text === text);
|
||||
expect(observation, `missing Observation: ${text}`).toBeDefined();
|
||||
expect(observation?.effectivePeriod).toEqual({
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
});
|
||||
expect(observation?.effectiveDateTime).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the reading instant for a single reading", () => {
|
||||
const bundle = fullBundle();
|
||||
const weight = resourcesOf(bundle)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation")
|
||||
.find((o) => o.code.coding?.[0].code === "29463-7");
|
||||
expect(weight?.effectiveDateTime).toBe("2026-04-30T08:00:00.000Z");
|
||||
expect(weight?.effectivePeriod).toBeUndefined();
|
||||
});
|
||||
|
||||
it("states a window for every cycle figure", () => {
|
||||
const bundle = fullBundle({
|
||||
cycle: {
|
||||
lastPeriodStart: "2026-04-20",
|
||||
recentCycles: [],
|
||||
observedCycleCount: 3,
|
||||
averageCycleLengthDays: 27.5,
|
||||
cycleLengthVariabilityDays: 0.5,
|
||||
averagePeriodLengthDays: 5,
|
||||
currentPhase: "LUTEAL",
|
||||
},
|
||||
});
|
||||
const cycleObservations = resourcesOf(bundle).filter(
|
||||
(r): r is FhirObservation =>
|
||||
r.resourceType === "Observation" && r.id.startsWith("obs-cycle-"),
|
||||
);
|
||||
expect(cycleObservations).toHaveLength(4);
|
||||
for (const observation of cycleObservations) {
|
||||
expect(observation.effectivePeriod?.end).toBe("2026-05-03T12:00:00.000Z");
|
||||
expect(observation.effectiveDateTime).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── W1 — the screener totals reach FHIR at all ───────────────────────────
|
||||
|
||||
describe("WHO-5 and SCI totals", () => {
|
||||
it("emit a text-only survey Observation rather than being dropped", () => {
|
||||
const bundle = fullBundle({
|
||||
measurements: {
|
||||
WHO5_SCORE: [{ value: 68, measuredAt: "2026-04-28T08:00:00.000Z" }],
|
||||
SCI_SCORE: [{ value: 24, measuredAt: "2026-04-28T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const observations = resourcesOf(bundle).filter(
|
||||
(r): r is FhirObservation => r.resourceType === "Observation",
|
||||
);
|
||||
for (const [display, value] of [
|
||||
["WHO-5 well-being index total score", 68],
|
||||
["Sleep Condition Indicator total score", 24],
|
||||
] as const) {
|
||||
const observation = observations.find((o) => o.code.text === display);
|
||||
expect(observation, display).toBeDefined();
|
||||
// No verified LOINC exists for either total, so no code is invented.
|
||||
expect(observation?.code.coding).toBeUndefined();
|
||||
expect(observation?.category?.[0].coding?.[0].code).toBe("survey");
|
||||
expect(observation?.valueQuantity?.value).toBe(value);
|
||||
expect(observation?.valueQuantity?.code).toBe("{score}");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 19 — UCUM allowlist over every registry facet ───────────────────
|
||||
|
||||
describe("UCUM coding", () => {
|
||||
it("either validates a registry unit or emits it uncoded", () => {
|
||||
const units = allSignals()
|
||||
.map((signal) => signal.fhir?.unit)
|
||||
.filter((unit): unit is string => typeof unit === "string");
|
||||
expect(units.length).toBeGreaterThan(20);
|
||||
for (const unit of units) {
|
||||
// The contract: the display unit always goes out, and `system` + `code`
|
||||
// follow exactly when the string is a UCUM expression. No unchecked
|
||||
// string ever reaches `Quantity.code`.
|
||||
const quantity = ucumQuantity(1, unit);
|
||||
expect(quantity.unit, unit).toBe(unit);
|
||||
if (isUcumCode(unit)) {
|
||||
expect(quantity.system, unit).toBe("http://unitsofmeasure.org");
|
||||
expect(quantity.code, unit).toBe(unit);
|
||||
} else {
|
||||
expect(quantity.system, unit).toBeUndefined();
|
||||
expect(quantity.code, unit).toBeUndefined();
|
||||
}
|
||||
}
|
||||
// The known non-UCUM display label in the registry stays uncoded.
|
||||
expect(units).toContain("dB[A]");
|
||||
expect(isUcumCode("dB[A]")).toBe(false);
|
||||
// …while the ordinary ones validate.
|
||||
for (const unit of ["kg", "%", "{score}", "Cel", "/min", "kg/m2"]) {
|
||||
expect(isUcumCode(unit), unit).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("stamps system and code together, or neither", () => {
|
||||
const bundle = fullBundle();
|
||||
const quantities = resourcesOf(bundle)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation")
|
||||
.flatMap((o) => [
|
||||
o.valueQuantity,
|
||||
...(o.component ?? []).map((c) => c.valueQuantity),
|
||||
...(o.referenceRange ?? []).flatMap((r) => [r.low, r.high]),
|
||||
])
|
||||
.filter((q) => q !== undefined);
|
||||
expect(quantities.length).toBeGreaterThan(5);
|
||||
for (const quantity of quantities) {
|
||||
if (quantity!.code !== undefined) {
|
||||
expect(quantity!.system).toBe("http://unitsofmeasure.org");
|
||||
}
|
||||
expect(quantity!.unit).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the bounds in the same coding as the value beside them", () => {
|
||||
const bundle = fullBundle();
|
||||
const hdl = resourcesOf(bundle)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation")
|
||||
.find((o) => o.code.text === "HDL");
|
||||
expect(hdl?.valueQuantity?.code).toBe("mg/dL");
|
||||
expect(hdl?.referenceRange?.[0].low).toEqual(
|
||||
hdl?.valueQuantity && {
|
||||
value: 40,
|
||||
unit: "mg/dL",
|
||||
system: "http://unitsofmeasure.org",
|
||||
code: "mg/dL",
|
||||
},
|
||||
);
|
||||
expect(hdl?.referenceRange?.[0].high?.system).toBe(
|
||||
"http://unitsofmeasure.org",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,12 +29,20 @@ vi.mock("@/lib/doctor-report-data", () => ({
|
||||
normaliseDateRange: vi.fn(() => ({ from: new Date(0), to: new Date() })),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
|
||||
// The REST face replays the owner's saved selection; this test is about the
|
||||
// module backstop, so the selection is fixed to one that names the insurance
|
||||
// leaf — otherwise the identity assertion below would be testing the
|
||||
// selection resolver instead.
|
||||
vi.mock("@/lib/report-selection/saved-profile", () => ({
|
||||
resolveSavedSelection: vi.fn(async () => selectionFromLeaves(["INSURANCE"])),
|
||||
}));
|
||||
|
||||
import { loadFhirContext } from "@/lib/fhir/rest";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
function setDoctorReportModule(enabled: boolean): void {
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
|
||||
|
||||
+193
-68
@@ -4,25 +4,25 @@
|
||||
* Pure function: takes the SAME `DoctorReportData` the PDF renderer consumes
|
||||
* (assembled by `collectDoctorReportData`) plus the decrypted patient
|
||||
* identity, and emits a `Bundle` of `type: "document"` — a leading
|
||||
* `Composition` "cover page", the `Patient`, then one `Observation` per
|
||||
* selected vital / glucose context / mood + one `MedicationStatement` per
|
||||
* active medication.
|
||||
* `Composition` "cover page", the `Device` that authored it, the `Patient`,
|
||||
* then one `Observation` per selected vital / glucose context / lab / mood,
|
||||
* one `MedicationStatement` per active medication, and the structured records.
|
||||
*
|
||||
* Because it reuses the PDF aggregator, the FHIR export and the PDF describe
|
||||
* identical numbers by construction (the source-of-truth property the two
|
||||
* PDF endpoints already share).
|
||||
*
|
||||
* v1.11.0 — the per-resource emitters (Observation / MedicationStatement /
|
||||
* MedicationAdministration / Patient / Coverage, with their LOINC/ATC/SNOMED/
|
||||
* UCUM codings + the `survey`-category wellness split) live in the shared
|
||||
* `./resources` module so the FHIR REST search routes reuse the SAME coding
|
||||
* logic. This builder composes them into the document Bundle.
|
||||
* The per-resource emitters live in `./resources`, shared with the FHIR REST
|
||||
* routes; this builder composes them into the document Bundle, mints the entry
|
||||
* identities, and rewrites every internal reference onto them.
|
||||
*
|
||||
* No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
|
||||
* (`./types`), matching the project's "hand-rolled over the documented wire"
|
||||
* convention. The `Composition.text` narrative is escaped plain text, never
|
||||
* user-supplied HTML (no markdown library, no `dangerouslySetInnerHTML`).
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import {
|
||||
@@ -46,14 +46,16 @@ import type {
|
||||
FhirBundle,
|
||||
FhirBundleEntry,
|
||||
FhirComposition,
|
||||
FhirDevice,
|
||||
FhirDiagnosticReport,
|
||||
FhirObservation,
|
||||
FhirReference,
|
||||
FhirResource,
|
||||
} from "@/lib/fhir/types";
|
||||
|
||||
// Re-export the shared coding constants + option types so existing importers
|
||||
// (capabilities + health-record routes) keep their `@/lib/fhir/build-bundle`
|
||||
// import path. The single source of truth now lives in `./resources`.
|
||||
// import path. The single source of truth lives in `./resources`.
|
||||
export {
|
||||
ATC_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
@@ -61,6 +63,12 @@ export {
|
||||
} from "@/lib/fhir/resources";
|
||||
export type { FhirBuildOptions, FhirPatientIdentity };
|
||||
|
||||
/** Namespace for the document's own identifier (`bdl-9`). */
|
||||
const BUNDLE_IDENTIFIER_SYSTEM = "https://healthlog.dev/fhir/bundle-id";
|
||||
|
||||
/** Resource id of the authoring `Device` the Composition names. */
|
||||
const AUTHOR_DEVICE_ID = "healthlog";
|
||||
|
||||
/** Escape the five XML-significant characters for the xhtml narrative. */
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
@@ -82,6 +90,68 @@ export interface FhirRecordInputs {
|
||||
familyHistory?: FamilyHistoryEntryDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The running build's version, for the authoring Device. Same resolution order
|
||||
* as `/api/version`: the image build arg wins so the exported document names
|
||||
* the release that produced it, with the package version as the local-dev
|
||||
* fallback.
|
||||
*/
|
||||
function runningVersion(): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_APP_VERSION?.trim() ||
|
||||
process.env.npm_package_version?.trim() ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `value` in which every relative reference
|
||||
* (`Observation/obs-1`) names the entry identity that actually carries the
|
||||
* resource.
|
||||
*
|
||||
* `bdl-7` resolves an intra-document reference against `Bundle.entry.fullUrl`,
|
||||
* and the fullUrls are URNs — so a relative reference resolves against the
|
||||
* base of the RETRIEVING server, i.e. somewhere else entirely, or nowhere. One
|
||||
* total walk over the assembled resources is what makes that impossible to get
|
||||
* partially right: an emitter that grows a new reference is covered the day it
|
||||
* lands, without a second place to remember.
|
||||
*
|
||||
* Copies rather than mutates: the emitters share module-level reference
|
||||
* constants (one `{ reference: "Patient/patient-1" }` object is the `subject`
|
||||
* of every resource in the tree), and rewriting in place would rewrite them
|
||||
* for every later build in the process.
|
||||
*
|
||||
* Contained `#`-refs (the Coverage payor Organization) are left alone — they
|
||||
* already resolve inside their own resource.
|
||||
*/
|
||||
function withResolvedReferences<T>(
|
||||
value: T,
|
||||
urnByReference: ReadonlyMap<string, string>,
|
||||
): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) =>
|
||||
withResolvedReferences(item, urnByReference),
|
||||
) as unknown as T;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] =
|
||||
key === "reference" && typeof child === "string"
|
||||
? (urnByReference.get(child) ?? child)
|
||||
: withResolvedReferences(child, urnByReference);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/** True for an Observation the vital-signs panel may legitimately claim. */
|
||||
function isVitalSignsObservation(observation: FhirObservation): boolean {
|
||||
return (observation.category ?? []).some((concept) =>
|
||||
(concept.coding ?? []).some((coding) => coding.code === "vital-signs"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FHIR R4 document Bundle from the aggregated report data.
|
||||
*
|
||||
@@ -97,29 +167,58 @@ export function buildFhirDocumentBundle(
|
||||
const patientRef: FhirReference = {
|
||||
reference: `Patient/${PATIENT_RESOURCE_ID}`,
|
||||
};
|
||||
const deviceRef: FhirReference = {
|
||||
reference: `Device/${AUTHOR_DEVICE_ID}`,
|
||||
display: "HealthLog",
|
||||
};
|
||||
const entries: FhirBundleEntry[] = [];
|
||||
const observationRefs: FhirReference[] = [];
|
||||
const vitalSignsObservationRefs: FhirReference[] = [];
|
||||
const cycleObservationRefs: FhirReference[] = [];
|
||||
const medicationRefs: FhirReference[] = [];
|
||||
const administrationRefs: FhirReference[] = [];
|
||||
const conditionRefs: FhirReference[] = [];
|
||||
const encounterRefs: FhirReference[] = [];
|
||||
const allergyRefs: FhirReference[] = [];
|
||||
const familyHistoryRefs: FhirReference[] = [];
|
||||
const coverageRefs: FhirReference[] = [];
|
||||
|
||||
/**
|
||||
* Mint the entry identity. `fullUrl` is a fresh RFC-4122 UUID per entry —
|
||||
* `resource.id` stays the human-readable `obs-3` a maintainer can follow
|
||||
* through a diff, while the identity a receiver resolves against is unique
|
||||
* across every document ever exported.
|
||||
*/
|
||||
const push = (resource: FhirResource): void => {
|
||||
entries.push({ fullUrl: `urn:uuid:${randomUUID()}`, resource });
|
||||
};
|
||||
|
||||
// --- Device (the Composition's author) ---------------------------------
|
||||
const device: FhirDevice = {
|
||||
resourceType: "Device",
|
||||
id: AUTHOR_DEVICE_ID,
|
||||
manufacturer: "HealthLog",
|
||||
deviceName: [{ name: "HealthLog", type: "manufacturer-name" }],
|
||||
version: [{ value: runningVersion() }],
|
||||
};
|
||||
push(device);
|
||||
|
||||
// --- Patient -----------------------------------------------------------
|
||||
const patient = patientResource(data, identity);
|
||||
entries.push({ fullUrl: `urn:uuid:${patient.id}`, resource: patient });
|
||||
push(patientResource(data, identity));
|
||||
|
||||
// --- Coverage (insurer; sits right after the Patient) ------------------
|
||||
const coverage = coverageResource(data, identity);
|
||||
if (coverage) {
|
||||
entries.push({ fullUrl: `urn:uuid:${coverage.id}`, resource: coverage });
|
||||
push(coverage);
|
||||
coverageRefs.push({ reference: `Coverage/${coverage.id}` });
|
||||
}
|
||||
|
||||
// --- Observations (vital / activity / lab / survey, in canonical order)-
|
||||
for (const obs of observationsFromReportData(data, identity, options)) {
|
||||
entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
|
||||
observationRefs.push({ reference: `Observation/${obs.id}` });
|
||||
for (const obs of observationsFromReportData(data)) {
|
||||
push(obs);
|
||||
const ref: FhirReference = { reference: `Observation/${obs.id}` };
|
||||
observationRefs.push(ref);
|
||||
if (isVitalSignsObservation(obs)) vitalSignsObservationRefs.push(ref);
|
||||
}
|
||||
|
||||
// --- Cycle / reproductive-health Observations (opt-in only) -----------
|
||||
@@ -127,19 +226,19 @@ export function buildFhirDocumentBundle(
|
||||
// so a reproductive-health finding is never mixed into the vital-signs
|
||||
// panel. Absent unless the user toggled the cycle section AND has data.
|
||||
for (const obs of cycleObservationsFromReportData(data)) {
|
||||
entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
|
||||
push(obs);
|
||||
cycleObservationRefs.push({ reference: `Observation/${obs.id}` });
|
||||
}
|
||||
|
||||
// --- MedicationStatement per active medication -------------------------
|
||||
for (const stmt of medicationStatementsFromReportData(data, options)) {
|
||||
entries.push({ fullUrl: `urn:uuid:${stmt.id}`, resource: stmt });
|
||||
push(stmt);
|
||||
medicationRefs.push({ reference: `MedicationStatement/${stmt.id}` });
|
||||
}
|
||||
|
||||
// --- MedicationAdministration per acted intake -------------------------
|
||||
for (const admin of medicationAdministrationsFromReportData(data, options)) {
|
||||
entries.push({ fullUrl: `urn:uuid:${admin.id}`, resource: admin });
|
||||
push(admin);
|
||||
administrationRefs.push({
|
||||
reference: `MedicationAdministration/${admin.id}`,
|
||||
});
|
||||
@@ -152,24 +251,25 @@ export function buildFhirDocumentBundle(
|
||||
// `code.text`) plus a bounding Encounter that references it.
|
||||
const { conditions, encounters } = conditionsFromReportData(data);
|
||||
for (const condition of conditions) {
|
||||
entries.push({ fullUrl: `urn:uuid:${condition.id}`, resource: condition });
|
||||
push(condition);
|
||||
conditionRefs.push({ reference: `Condition/${condition.id}` });
|
||||
}
|
||||
for (const encounter of encounters) {
|
||||
entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter });
|
||||
push(encounter);
|
||||
encounterRefs.push({ reference: `Encounter/${encounter.id}` });
|
||||
}
|
||||
|
||||
// --- AllergyIntolerance per recorded allergy (v1.25) -------------------
|
||||
// Always-available structured records (not time-windowed). Patient-reported
|
||||
// (verificationStatus unconfirmed); the substance rides `code.text`.
|
||||
for (const allergy of allergyIntoleranceResources(records.allergies ?? [])) {
|
||||
entries.push({ fullUrl: `urn:uuid:${allergy.id}`, resource: allergy });
|
||||
push(allergy);
|
||||
allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` });
|
||||
}
|
||||
|
||||
// --- FamilyMemberHistory per recorded entry (v1.25) -------------------
|
||||
for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) {
|
||||
entries.push({ fullUrl: `urn:uuid:${fmh.id}`, resource: fmh });
|
||||
push(fmh);
|
||||
familyHistoryRefs.push({ reference: `FamilyMemberHistory/${fmh.id}` });
|
||||
}
|
||||
|
||||
@@ -190,6 +290,32 @@ export function buildFhirDocumentBundle(
|
||||
: []),
|
||||
].join(" ");
|
||||
|
||||
const diagnosticReport: FhirDiagnosticReport = {
|
||||
resourceType: "DiagnosticReport",
|
||||
id: "diagnostic-report-1",
|
||||
status: "final",
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: "85353-1",
|
||||
display:
|
||||
"Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
|
||||
},
|
||||
],
|
||||
text: "Vital signs panel",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: { start: data.period.start, end: data.period.end },
|
||||
// A vital-signs panel reports vital signs. Routing labs, adherence rates
|
||||
// and survey scores through it would let a receiver read a questionnaire
|
||||
// total as a vital sign because the panel code said so.
|
||||
result: vitalSignsObservationRefs,
|
||||
};
|
||||
const diagnosticReportRef: FhirReference = {
|
||||
reference: `DiagnosticReport/${diagnosticReport.id}`,
|
||||
};
|
||||
|
||||
const composition: FhirComposition = {
|
||||
resourceType: "Composition",
|
||||
id: "composition-1",
|
||||
@@ -206,30 +332,30 @@ export function buildFhirDocumentBundle(
|
||||
},
|
||||
subject: patientRef,
|
||||
date: now.toISOString(),
|
||||
author: [{ reference: "Device/healthlog", display: "HealthLog" }],
|
||||
author: [deviceRef],
|
||||
title: "Health Record",
|
||||
// v1.9.0 — top-level document narrative (Coverage nit 2). Reuses the
|
||||
// same escaped plain-text summary the Vital-signs section carries, so
|
||||
// a strict US-Core-style validator sees a `Composition.text`.
|
||||
// v1.9.0 — top-level document narrative. Section narrative is already
|
||||
// present; a strict US-Core-style validator additionally expects a
|
||||
// `Composition.text`. Reuses the same escaped plain-text summary.
|
||||
text: {
|
||||
status: "generated",
|
||||
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
|
||||
},
|
||||
section: [
|
||||
{
|
||||
// Vital-signs section carries the narrative + every Observation ref,
|
||||
// matching the iOS Bundle graph ("Vital signs" + "Medications").
|
||||
// Vital-signs section carries the narrative + every Observation ref.
|
||||
// NOT the Patient: `Composition.subject` above already names the
|
||||
// person the whole document is about, and a subject listed as a
|
||||
// section entry reads as a finding.
|
||||
title: "Vital signs",
|
||||
text: {
|
||||
status: "generated",
|
||||
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
|
||||
},
|
||||
entry: [patientRef, ...observationRefs],
|
||||
entry: [...observationRefs, diagnosticReportRef],
|
||||
},
|
||||
// Medications section carries both the active-medication
|
||||
// statements and the per-dose administration records (v1.9.0), so
|
||||
// the iOS two-section graph ("Vital signs" + "Medications") is
|
||||
// preserved without adding a third section.
|
||||
// statements and the per-dose administration records (v1.9.0).
|
||||
...(medicationRefs.length > 0 || administrationRefs.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -248,14 +374,15 @@ export function buildFhirDocumentBundle(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// v1.18.1 — opt-in illness/condition section. Only present when the
|
||||
// illness module surfaced episodes. Lists the Condition resources (the
|
||||
// Encounter rides each Condition's reasonReference).
|
||||
// v1.18.1 — opt-in illness/condition section. Lists the Condition
|
||||
// resources AND the Encounters that bound them: an Encounter reachable
|
||||
// only from a Condition's `reasonReference` is not reachable from the
|
||||
// document at all.
|
||||
...(conditionRefs.length > 0
|
||||
? [
|
||||
{
|
||||
title: "Conditions",
|
||||
entry: conditionRefs,
|
||||
entry: [...conditionRefs, ...encounterRefs],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -278,50 +405,48 @@ export function buildFhirDocumentBundle(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// The insurance relationship. A Coverage no section reaches is a
|
||||
// resource the document carries and never mentions.
|
||||
...(coverageRefs.length > 0
|
||||
? [
|
||||
{
|
||||
title: "Insurance",
|
||||
entry: coverageRefs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
// --- DiagnosticReport (vital-signs panel; routes all Observations) -----
|
||||
// Last entry in the Bundle, matching the iOS Bundle graph. `result[]`
|
||||
// references every emitted Observation.
|
||||
const diagnosticReport: FhirDiagnosticReport = {
|
||||
resourceType: "DiagnosticReport",
|
||||
id: "diagnostic-report-1",
|
||||
status: "final",
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: "85353-1",
|
||||
display:
|
||||
"Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
|
||||
},
|
||||
],
|
||||
text: "Vital signs panel",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: { start: data.period.start, end: data.period.end },
|
||||
result: observationRefs,
|
||||
};
|
||||
|
||||
// The Composition must be the FIRST entry in a document Bundle; the
|
||||
// DiagnosticReport is the LAST.
|
||||
const orderedEntries: FhirBundleEntry[] = [
|
||||
{
|
||||
fullUrl: `urn:uuid:${composition.id}`,
|
||||
resource: composition as FhirResource,
|
||||
},
|
||||
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: composition },
|
||||
...entries,
|
||||
{
|
||||
fullUrl: `urn:uuid:${diagnosticReport.id}`,
|
||||
resource: diagnosticReport as FhirResource,
|
||||
},
|
||||
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport },
|
||||
];
|
||||
|
||||
// Every entry identity is known only now, so the reference rewrite runs once
|
||||
// over the finished set.
|
||||
const urnByReference = new Map<string, string>(
|
||||
orderedEntries.map((entry) => [
|
||||
`${entry.resource.resourceType}/${entry.resource.id}`,
|
||||
entry.fullUrl,
|
||||
]),
|
||||
);
|
||||
const resolvedEntries: FhirBundleEntry[] = orderedEntries.map((entry) => ({
|
||||
fullUrl: entry.fullUrl,
|
||||
resource: withResolvedReferences(entry.resource, urnByReference),
|
||||
}));
|
||||
|
||||
return {
|
||||
resourceType: "Bundle",
|
||||
type: "document",
|
||||
identifier: {
|
||||
system: BUNDLE_IDENTIFIER_SYSTEM,
|
||||
value: randomUUID(),
|
||||
},
|
||||
timestamp: now.toISOString(),
|
||||
entry: orderedEntries,
|
||||
entry: resolvedEntries,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Shared anchors for the per-resource FHIR R4 emitters: the patient id every
|
||||
* subject reference points at, the terminology-system URIs, the option /
|
||||
* identity types threaded from the export route, and the small concept
|
||||
* helpers each emitter reuses.
|
||||
*
|
||||
* Kept in its own module so `patient` / `coverage` / `observation` / `labs` /
|
||||
* `glucose` / `medications` / `conditions` / `cycle` can depend on the anchors
|
||||
* without depending on each other.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
LOINC_SYSTEM,
|
||||
HEALTHKIT_CODESYSTEM,
|
||||
type LoincMapping,
|
||||
} from "@/lib/fhir/loinc-map";
|
||||
import type { FhirCodeableConcept, FhirReference } from "@/lib/fhir/types";
|
||||
|
||||
/** Patient identity not carried in `DoctorReportData` (KVNR is encrypted). */
|
||||
export interface FhirPatientIdentity {
|
||||
/** German KVNR (decrypted by the route). */
|
||||
insuranceNumber: string | null;
|
||||
}
|
||||
|
||||
/** KVNR identifier namespace per the gematik SID. */
|
||||
export const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10";
|
||||
|
||||
/** German insurer institution-number (IKNR) identifier namespace. */
|
||||
export const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
|
||||
|
||||
/**
|
||||
* v1.9.0 — drug-coding system URIs. ATC is the portable WHO default
|
||||
* (the iOS export emits the identical URI); RxNorm is the secondary US
|
||||
* coding. Both are additive `coding[]` entries on the same concept; the
|
||||
* free-text `.text` (the user's medication name) stays the anchor.
|
||||
*/
|
||||
export const ATC_SYSTEM = "http://www.whocc.no/atc";
|
||||
/**
|
||||
* German national ATC URI maintained by the BfArM. Same ATC classification
|
||||
* as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
|
||||
* a replacement) when a German-region export is requested, so the WHO entry
|
||||
* stays first and byte-identical for every consumer.
|
||||
*/
|
||||
export const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
|
||||
export const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
|
||||
/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
|
||||
export const SNOMED_SYSTEM = "http://snomed.info/sct";
|
||||
|
||||
/**
|
||||
* The app locales for which a health-record export defaults `germanAtc` on
|
||||
* (the additive BfArM ATC coding). The export route derives the flag from
|
||||
* the user's locale against this set; the capabilities endpoint surfaces it
|
||||
* verbatim so a client can predict the coding without a round-trip. Keeping
|
||||
* it here — beside the BfArM URI it gates — makes the two move together.
|
||||
*/
|
||||
export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const;
|
||||
|
||||
/** Options threaded from the export route into the emitters. Additive, all defaulted. */
|
||||
export interface FhirBuildOptions {
|
||||
/**
|
||||
* When true, additionally emit the German BfArM ATC URI alongside the WHO
|
||||
* entry on each medication concept. The WHO coding stays first and
|
||||
* byte-identical; this only appends a second URI for the same leaf code.
|
||||
* Defaults off; the route turns it on for a German-region export.
|
||||
*/
|
||||
germanAtc?: boolean;
|
||||
}
|
||||
|
||||
/** Local-`#`-ref / patient anchor id shared by every subject reference. */
|
||||
export const PATIENT_RESOURCE_ID = "patient-1";
|
||||
|
||||
export const patientRef: FhirReference = {
|
||||
reference: `Patient/${PATIENT_RESOURCE_ID}`,
|
||||
};
|
||||
|
||||
export function categoryConcept(category: string): FhirCodeableConcept {
|
||||
return {
|
||||
coding: [
|
||||
{
|
||||
system: "http://terminology.hl7.org/CodeSystem/observation-category",
|
||||
code: category,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept {
|
||||
if (m.loinc) {
|
||||
// HealthKit placeholder codes have no published LOINC term; they must not
|
||||
// sit under the LOINC namespace (a non-LOINC code there is a conformance
|
||||
// violation). Route them onto the shared custom CodeSystem instead —
|
||||
// byte-aligned with the iOS exporter.
|
||||
const system = m.loinc.startsWith("HKQuantityTypeIdentifier")
|
||||
? HEALTHKIT_CODESYSTEM
|
||||
: LOINC_SYSTEM;
|
||||
return {
|
||||
coding: [{ system, code: m.loinc, display: m.display }],
|
||||
text: m.display,
|
||||
};
|
||||
}
|
||||
// No stable LOINC — local text-only concept (documented fallback).
|
||||
return { text: m.display };
|
||||
}
|
||||
|
||||
/** Latest `{ value, measuredAt }` for a type, or null when no rows. */
|
||||
export function latestReading(
|
||||
data: DoctorReportData,
|
||||
type: string,
|
||||
): { value: number; measuredAt: string } | null {
|
||||
const series = data.measurements[type];
|
||||
if (!series || series.length === 0) return null;
|
||||
return series[series.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints the shared `obs-N` sequence. The vital / BP / BMI / glucose / lab /
|
||||
* adherence / mood / wellness emitters all draw from ONE allocator so the ids
|
||||
* run as a single continuous run regardless of which blocks contributed —
|
||||
* splitting the emitters across modules must not renumber the output.
|
||||
*/
|
||||
export function observationIdSequence(): () => string {
|
||||
let seq = 0;
|
||||
return () => `obs-${(seq += 1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reporting window an aggregate Observation describes. Every value derived
|
||||
* OVER the period (a mean, a share, a rate, an average) carries this rather
|
||||
* than an `effectiveDateTime` pinned to the window's last instant — that
|
||||
* timestamp asserts a reading that was never taken.
|
||||
*/
|
||||
export function reportingPeriod(data: DoctorReportData): {
|
||||
start: string;
|
||||
end: string;
|
||||
} {
|
||||
return { start: data.period.start, end: data.period.end };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* `Condition` + bounding `Encounter` per illness / condition episode.
|
||||
*
|
||||
* HealthLog records a patient-kept condition journal; it is NOT a diagnosing
|
||||
* device and asserts no specific ICD/SNOMED diagnosis. The user's own label
|
||||
* rides `code.text`; the coded `code` carries the BROAD SNOMED CT category for
|
||||
* the episode's `IllnessType`, with the generic "Disease" root as the honest
|
||||
* fallback for an unknown / future type.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
|
||||
import type {
|
||||
FhirCodeableConcept,
|
||||
FhirCondition,
|
||||
FhirEncounter,
|
||||
} from "@/lib/fhir/types";
|
||||
import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common";
|
||||
|
||||
/** SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. */
|
||||
const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
|
||||
|
||||
/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
|
||||
function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
|
||||
return {
|
||||
coding: [
|
||||
{
|
||||
system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
code: resolved ? "resolved" : "active",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 P4 — emit one `Condition` per illness/condition episode plus a
|
||||
* bounding `Encounter`. Absent unless the illness module is enabled AND the
|
||||
* window held an episode (the aggregator only populates `illnessEpisodes`
|
||||
* then). Ids run `condition-1..N` / `encounter-1..N`; the Encounter references
|
||||
* its Condition so a clinician sees the time-boxed episode.
|
||||
*/
|
||||
export function conditionsFromReportData(data: DoctorReportData): {
|
||||
conditions: FhirCondition[];
|
||||
encounters: FhirEncounter[];
|
||||
} {
|
||||
const episodes = data.illnessEpisodes;
|
||||
if (!episodes || episodes.length === 0) {
|
||||
return { conditions: [], encounters: [] };
|
||||
}
|
||||
const conditions: FhirCondition[] = [];
|
||||
const encounters: FhirEncounter[] = [];
|
||||
let seq = 0;
|
||||
for (const ep of episodes) {
|
||||
seq += 1;
|
||||
const conditionId = `condition-${seq}`;
|
||||
const resolved = ep.resolvedAt !== null;
|
||||
conditions.push({
|
||||
resourceType: "Condition",
|
||||
id: conditionId,
|
||||
clinicalStatus: conditionClinicalStatus(resolved),
|
||||
verificationStatus: {
|
||||
coding: [
|
||||
{
|
||||
system:
|
||||
"http://terminology.hl7.org/CodeSystem/condition-ver-status",
|
||||
// Patient-reported journal entry → "unconfirmed", never a
|
||||
// clinician-confirmed diagnosis.
|
||||
code: "unconfirmed",
|
||||
},
|
||||
],
|
||||
},
|
||||
category: [
|
||||
{
|
||||
coding: [
|
||||
{
|
||||
system:
|
||||
"http://terminology.hl7.org/CodeSystem/condition-category",
|
||||
code: "problem-list-item",
|
||||
},
|
||||
],
|
||||
// The journal's broad class as a human-readable category label.
|
||||
text: ep.type,
|
||||
},
|
||||
],
|
||||
code: {
|
||||
// Broad SNOMED CT category for the journal type; the generic "Disease"
|
||||
// root is the honest fallback for an unknown / future type. NEVER a
|
||||
// specific diagnosis — the user's own label stays on `.text`.
|
||||
coding: [
|
||||
{
|
||||
system: SNOMED_SYSTEM,
|
||||
code: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).code,
|
||||
display: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).display,
|
||||
},
|
||||
],
|
||||
text: ep.label,
|
||||
},
|
||||
subject: patientRef,
|
||||
onsetDateTime: ep.onsetAt,
|
||||
...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
|
||||
note: [
|
||||
{
|
||||
text: `Self-recorded ${ep.lifecycle.toLowerCase().replace(/_/g, " ")} condition journal entry; patient-reported, not a clinical diagnosis.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
encounters.push({
|
||||
resourceType: "Encounter",
|
||||
id: `encounter-${seq}`,
|
||||
status: resolved ? "finished" : "in-progress",
|
||||
class: {
|
||||
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||||
code: "AMB",
|
||||
display: "ambulatory",
|
||||
},
|
||||
subject: patientRef,
|
||||
period: {
|
||||
start: ep.onsetAt,
|
||||
...(ep.resolvedAt ? { end: ep.resolvedAt } : {}),
|
||||
},
|
||||
reasonReference: [{ reference: `Condition/${conditionId}` }],
|
||||
});
|
||||
}
|
||||
return { conditions, encounters };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The `Coverage` resource — the insurer relationship, with the payor as a
|
||||
* CONTAINED `Organization` referenced by a local `#`-ref.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { FhirCoverage, FhirOrganization } from "@/lib/fhir/types";
|
||||
import {
|
||||
IKNR_SYSTEM,
|
||||
patientRef,
|
||||
type FhirPatientIdentity,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
|
||||
/**
|
||||
* Emit the `Coverage`, or `null` when no payor is known.
|
||||
*
|
||||
* R4 marks `Coverage.payor` as `1..*`: a Coverage names who pays, and one that
|
||||
* cannot is not a Coverage. A record that carries only a member id (a bare
|
||||
* KVNR, no insurer name and no IKNR) therefore emits none — nothing is lost,
|
||||
* because the member id already rides `Patient.identifier` and doubles as the
|
||||
* `subscriberId` when a real Coverage does go out.
|
||||
*/
|
||||
export function coverageResource(
|
||||
data: DoctorReportData,
|
||||
identity: FhirPatientIdentity,
|
||||
): FhirCoverage | null {
|
||||
const insurerName = data.patient.insurerName ?? null;
|
||||
const insurerIkNumber = data.patient.insurerIkNumber ?? null;
|
||||
if (!insurerName && !insurerIkNumber) return null;
|
||||
|
||||
const orgId = "insurer-org-1";
|
||||
const payorOrg: FhirOrganization = {
|
||||
resourceType: "Organization",
|
||||
id: orgId,
|
||||
};
|
||||
if (insurerIkNumber) {
|
||||
payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }];
|
||||
}
|
||||
if (insurerName) payorOrg.name = insurerName;
|
||||
|
||||
const coverage: FhirCoverage = {
|
||||
resourceType: "Coverage",
|
||||
id: "coverage-1",
|
||||
status: "active",
|
||||
beneficiary: patientRef,
|
||||
contained: [payorOrg],
|
||||
payor: [{ reference: `#${orgId}` }],
|
||||
};
|
||||
if (identity.insuranceNumber) {
|
||||
coverage.subscriberId = identity.insuranceNumber;
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* v1.15.0 — the cycle / reproductive-health Observations from the opt-in cycle
|
||||
* summary: LMP (LOINC 8665-2, a date value), average cycle length (64700-8,
|
||||
* days), average period length (64698-4, days), and the current phase
|
||||
* (text-only survey finding). Absent when the aggregator carried no cycle
|
||||
* summary (toggle off or no observed cycle). Ids run `obs-cycle-N` so they
|
||||
* never collide with the main `obs-N` sequence.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
LOINC_SYSTEM,
|
||||
LMP_LOINC,
|
||||
CYCLE_LENGTH_LOINC,
|
||||
PERIOD_LENGTH_LOINC,
|
||||
} from "@/lib/fhir/loinc-map";
|
||||
import { ucumQuantity } from "@/lib/fhir/ucum";
|
||||
import type { FhirObservation } from "@/lib/fhir/types";
|
||||
import {
|
||||
categoryConcept,
|
||||
patientRef,
|
||||
reportingPeriod,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
|
||||
/**
|
||||
* Phase → display string for the current-phase Observation. The four cycle
|
||||
* phases have no single published LOINC answer-list term we commit to, so the
|
||||
* phase rides a text-only concept under the `survey` category (a clinician's
|
||||
* viewer reads it as a descriptive finding, not a coded diagnosis), mirroring
|
||||
* the wellness-score stance.
|
||||
*/
|
||||
const CYCLE_PHASE_DISPLAY: Record<string, string> = {
|
||||
MENSTRUAL: "Menstrual phase",
|
||||
FOLLICULAR: "Follicular phase",
|
||||
OVULATORY: "Ovulatory phase",
|
||||
LUTEAL: "Luteal phase",
|
||||
};
|
||||
|
||||
export function cycleObservationsFromReportData(
|
||||
data: DoctorReportData,
|
||||
): FhirObservation[] {
|
||||
const cycle = data.cycle;
|
||||
if (!cycle) return [];
|
||||
const observations: FhirObservation[] = [];
|
||||
let seq = 0;
|
||||
const next = () => `obs-cycle-${(seq += 1)}`;
|
||||
// Every cycle figure is derived across the whole window, not read at its
|
||||
// last instant.
|
||||
const effectivePeriod = reportingPeriod(data);
|
||||
|
||||
if (cycle.lastPeriodStart) {
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: next(),
|
||||
status: "final",
|
||||
category: [categoryConcept("survey")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: LMP_LOINC,
|
||||
display: "Last menstrual period start date",
|
||||
},
|
||||
],
|
||||
text: "Last menstrual period (LMP)",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod,
|
||||
// LMP is a date value; emit the YYYY-MM-DD as a FHIR `date`.
|
||||
valueDateTime: cycle.lastPeriodStart,
|
||||
});
|
||||
}
|
||||
|
||||
if (cycle.averageCycleLengthDays !== null) {
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: next(),
|
||||
status: "final",
|
||||
category: [categoryConcept("survey")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: CYCLE_LENGTH_LOINC,
|
||||
display: "Menstrual cycle length",
|
||||
},
|
||||
],
|
||||
text: "Average menstrual cycle length",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod,
|
||||
valueQuantity: ucumQuantity(cycle.averageCycleLengthDays, "d"),
|
||||
...(cycle.cycleLengthVariabilityDays !== null
|
||||
? {
|
||||
note: [
|
||||
{
|
||||
text: `Cycle-length variability (median absolute deviation): ±${cycle.cycleLengthVariabilityDays} days over ${cycle.observedCycleCount} observed cycle(s).`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (cycle.averagePeriodLengthDays !== null) {
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: next(),
|
||||
status: "final",
|
||||
category: [categoryConcept("survey")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: PERIOD_LENGTH_LOINC,
|
||||
display: "Length of menses",
|
||||
},
|
||||
],
|
||||
text: "Average period length",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod,
|
||||
valueQuantity: ucumQuantity(cycle.averagePeriodLengthDays, "d"),
|
||||
});
|
||||
}
|
||||
|
||||
if (cycle.currentPhase) {
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: next(),
|
||||
status: "final",
|
||||
category: [categoryConcept("survey")],
|
||||
code: { text: "Current menstrual cycle phase" },
|
||||
subject: patientRef,
|
||||
effectivePeriod,
|
||||
valueString:
|
||||
CYCLE_PHASE_DISPLAY[cycle.currentPhase] ?? cycle.currentPhase,
|
||||
note: [
|
||||
{
|
||||
text: "Phase derived from logged cycle boundaries; descriptive, not a clinical assessment.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Glucose Observations: the per-context reading and the clinical panel
|
||||
* (time-in-range, mean, GMI, estimated A1C, variability) the one
|
||||
* literature-locked engine computes for the report period.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
resolveGlucoseUnit,
|
||||
convertGlucose,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import {
|
||||
LOINC_SYSTEM,
|
||||
GLUCOSE_LOINC,
|
||||
GLUCOSE_TIR_LOINC,
|
||||
GLUCOSE_GMI_LOINC,
|
||||
GLUCOSE_MEAN_LOINC,
|
||||
GLUCOSE_EA1C_LOINC,
|
||||
} from "@/lib/fhir/loinc-map";
|
||||
import { ucumQuantity } from "@/lib/fhir/ucum";
|
||||
import type { FhirObservation } from "@/lib/fhir/types";
|
||||
import {
|
||||
categoryConcept,
|
||||
patientRef,
|
||||
reportingPeriod,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
|
||||
/**
|
||||
* The time-in-range band, stated in the unit the reader uses. The band itself
|
||||
* is fixed at 70–180 mg/dL [Battelino 2019]; only its presentation converts,
|
||||
* exactly as the sibling mean-glucose value does. Printing "70–180 mg/dL" to a
|
||||
* mmol/L reader hands them a number they cannot compare against their own.
|
||||
*/
|
||||
function timeInRangeDisplay(unit: GlucoseUnit): string {
|
||||
const low = convertGlucose(70, unit);
|
||||
const high = convertGlucose(180, unit);
|
||||
return `Glucose time in range (${low}–${high} ${unit})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Observation per recorded glucose context, valued in the user's display
|
||||
* unit. The value is the context's LATEST reading but the code is a context
|
||||
* summary, so it carries the reporting period rather than a reading instant.
|
||||
*/
|
||||
export function glucoseContextObservations(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||||
const observations: FhirObservation[] = [];
|
||||
for (const [ctx, stat] of Object.entries(data.glucoseStats)) {
|
||||
const map = GLUCOSE_LOINC[ctx];
|
||||
if (!map) continue;
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("laboratory")],
|
||||
code: {
|
||||
coding: [
|
||||
{ system: LOINC_SYSTEM, code: map.loinc, display: map.display },
|
||||
],
|
||||
text: map.display,
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: reportingPeriod(data),
|
||||
valueQuantity: ucumQuantity(
|
||||
convertGlucose(stat.latest, glucoseUnit),
|
||||
glucoseUnit,
|
||||
),
|
||||
});
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clinical glucose panel (v1.18.0). Emitted only when readings exist — the
|
||||
* aggregator zeroes the panel when the glucose module is off, so an absent
|
||||
* panel means the module is off OR there is no data. Each metric carries its
|
||||
* published LOINC where one exists; the coefficient of variation has none and
|
||||
* rides a `survey` text-only concept.
|
||||
*/
|
||||
export function glucoseClinicalObservations(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const clinical = data.glucoseClinical;
|
||||
if (!clinical || clinical.readingCount <= 0) return [];
|
||||
|
||||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||||
const period = reportingPeriod(data);
|
||||
const observations: FhirObservation[] = [];
|
||||
|
||||
const push = (opts: {
|
||||
loinc?: string;
|
||||
text: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
survey?: boolean;
|
||||
}) => {
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept(opts.survey ? "survey" : "laboratory")],
|
||||
code: opts.loinc
|
||||
? {
|
||||
coding: [
|
||||
{ system: LOINC_SYSTEM, code: opts.loinc, display: opts.text },
|
||||
],
|
||||
text: opts.text,
|
||||
}
|
||||
: { text: opts.text },
|
||||
subject: patientRef,
|
||||
effectivePeriod: period,
|
||||
valueQuantity: ucumQuantity(opts.value, opts.unit),
|
||||
...(clinical.isSpotEstimate
|
||||
? {
|
||||
note: [
|
||||
{
|
||||
text: "Spot-reading estimate from individual measurements, not a continuous-monitor profile.",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (clinical.distribution) {
|
||||
push({
|
||||
loinc: GLUCOSE_TIR_LOINC,
|
||||
text: timeInRangeDisplay(glucoseUnit),
|
||||
value: Math.round(clinical.distribution.tir * 1000) / 10,
|
||||
unit: "%",
|
||||
});
|
||||
}
|
||||
if (clinical.meanMgdl !== null) {
|
||||
push({
|
||||
loinc: GLUCOSE_MEAN_LOINC,
|
||||
text: "Mean glucose",
|
||||
value: convertGlucose(clinical.meanMgdl, glucoseUnit),
|
||||
unit: glucoseUnit,
|
||||
});
|
||||
}
|
||||
if (clinical.gmi !== null) {
|
||||
push({
|
||||
loinc: GLUCOSE_GMI_LOINC,
|
||||
text: "Glucose Management Indicator (GMI)",
|
||||
value: Math.round(clinical.gmi * 10) / 10,
|
||||
unit: "%",
|
||||
});
|
||||
}
|
||||
if (clinical.estimatedA1c !== null) {
|
||||
push({
|
||||
loinc: GLUCOSE_EA1C_LOINC,
|
||||
text: "Estimated A1C",
|
||||
value: Math.round(clinical.estimatedA1c * 10) / 10,
|
||||
unit: "%",
|
||||
});
|
||||
}
|
||||
if (clinical.variability !== null) {
|
||||
push({
|
||||
text: "Glucose variability (coefficient of variation)",
|
||||
value: Math.round(clinical.variability.cv * 10) / 10,
|
||||
unit: "%",
|
||||
survey: true,
|
||||
});
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* v1.11.0 — shared per-resource FHIR R4 emitters.
|
||||
*
|
||||
* The Observation / MedicationStatement / MedicationAdministration / Patient /
|
||||
* Coverage / Condition / Encounter / cycle builders (with their
|
||||
* LOINC/ATC/SNOMED/UCUM codings and the `survey` wellness split) are small pure
|
||||
* functions over the SAME `DoctorReportData` the document-bundle builder
|
||||
* consumes. This barrel is their one public face: `buildFhirDocumentBundle`
|
||||
* composes them into a `type: "document"` Bundle, and the FHIR REST routes wrap
|
||||
* them in a `type: "searchset"` Bundle. The coding logic has exactly one home,
|
||||
* so the document export and the REST face can never drift apart.
|
||||
*
|
||||
* No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
|
||||
* (`../types`), matching the project's "hand-rolled over the documented wire"
|
||||
* convention. All text is escaped plain text; never user-supplied HTML
|
||||
* (no markdown library, no `dangerouslySetInnerHTML`).
|
||||
*/
|
||||
export {
|
||||
ATC_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
PATIENT_RESOURCE_ID,
|
||||
type FhirBuildOptions,
|
||||
type FhirPatientIdentity,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
|
||||
export {
|
||||
administrativeGender,
|
||||
patientResource,
|
||||
} from "@/lib/fhir/resources/patient";
|
||||
export { coverageResource } from "@/lib/fhir/resources/coverage";
|
||||
export { observationsFromReportData } from "@/lib/fhir/resources/observation";
|
||||
export { cycleObservationsFromReportData } from "@/lib/fhir/resources/cycle";
|
||||
export { conditionsFromReportData } from "@/lib/fhir/resources/conditions";
|
||||
export {
|
||||
medicationStatementsFromReportData,
|
||||
medicationAdministrationsFromReportData,
|
||||
} from "@/lib/fhir/resources/medications";
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Lab-result Observations (v1.17.1; LOINC + UCUM v1.18.8).
|
||||
*
|
||||
* One `laboratory` Observation per recorded analyte. `analyte` + `unit` are
|
||||
* user-recorded free text (no closed LOINC enum). A curated analyte→LOINC map
|
||||
* (`resolveLabCoding`) adds a real LOINC `coding` ALONGSIDE the user's
|
||||
* `code.text` for the common biomarkers, and stamps the canonical UCUM `code`
|
||||
* on the value WHEN the recorded unit matches the mapped canonical symbol. An
|
||||
* analyte that does NOT resolve keeps the honest text-only `code` +
|
||||
* display-only `unit` (no fabricated terminology). The lab's reference bounds
|
||||
* map onto R4 `referenceRange`.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
|
||||
import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
|
||||
import { qualitativeValueConcept } from "@/lib/fhir/lab-qualitative";
|
||||
|
||||
import type {
|
||||
FhirObservation,
|
||||
FhirObservationReferenceRange,
|
||||
} from "@/lib/fhir/types";
|
||||
import { categoryConcept, patientRef } from "@/lib/fhir/resources/common";
|
||||
|
||||
export function labObservations(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const observations: FhirObservation[] = [];
|
||||
|
||||
for (const lab of data.labResults ?? []) {
|
||||
// Forward-compat: when the DTO carries a resolved canonical analyte name,
|
||||
// thread it as the third arg so the catalog name wins over the free-text
|
||||
// `analyte`. Absent today.
|
||||
const coding = resolveLabCoding(lab.analyte, lab.unit);
|
||||
const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
|
||||
const code = coding
|
||||
? {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: coding.loinc,
|
||||
display: coding.display,
|
||||
},
|
||||
],
|
||||
text,
|
||||
}
|
||||
: { text };
|
||||
|
||||
// v1.18.9 — a qualitative result (`value === null`) emits a
|
||||
// `valueCodeableConcept` (SNOMED qualifier value when confidently known,
|
||||
// else text-only) and NO `valueQuantity` / `referenceRange` — bounds are
|
||||
// meaningless for it. A numeric result keeps the established
|
||||
// valueQuantity + LOINC/UCUM path.
|
||||
if (lab.value === null) {
|
||||
const valueText = lab.valueText?.trim() ?? "";
|
||||
// A row with neither a number nor a result term states nothing. An
|
||||
// Observation whose only value is an empty concept is worse than no
|
||||
// Observation: it asserts a result was recorded and then withholds it.
|
||||
if (valueText === "") continue;
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("laboratory")],
|
||||
code,
|
||||
subject: patientRef,
|
||||
effectiveDateTime: lab.takenAt,
|
||||
valueCodeableConcept: qualitativeValueConcept(valueText),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// A lab quantity: the recorded `unit` is always the display string, and
|
||||
// the canonical UCUM `code` is stamped only when the analyte map resolved
|
||||
// one for exactly that unit (no coerced symbol).
|
||||
const quantity = (value: number) => ({
|
||||
value,
|
||||
unit: lab.unit,
|
||||
system: UCUM_SYSTEM,
|
||||
...(coding?.ucum ? { code: coding.ucum } : {}),
|
||||
});
|
||||
// The bounds are stated in the SAME unit as the value, so they carry the
|
||||
// SAME coding — a bare unit beside a coded value leaves a receiver unable
|
||||
// to compare the two.
|
||||
const referenceRange: FhirObservationReferenceRange[] | undefined =
|
||||
lab.referenceLow !== null || lab.referenceHigh !== null
|
||||
? [
|
||||
{
|
||||
...(lab.referenceLow !== null
|
||||
? { low: quantity(lab.referenceLow) }
|
||||
: {}),
|
||||
...(lab.referenceHigh !== null
|
||||
? { high: quantity(lab.referenceHigh) }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("laboratory")],
|
||||
code,
|
||||
subject: patientRef,
|
||||
effectiveDateTime: lab.takenAt,
|
||||
valueQuantity: quantity(lab.value),
|
||||
...(referenceRange ? { referenceRange } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* `MedicationStatement` (one per active medication) and
|
||||
* `MedicationAdministration` (one per acted intake), with their additive
|
||||
* ATC / RxNorm codings and the dose / route / site SNOMED anchors.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
|
||||
import type {
|
||||
FhirCodeableConcept,
|
||||
FhirDosage,
|
||||
FhirMedicationAdministration,
|
||||
FhirMedicationStatement,
|
||||
} from "@/lib/fhir/types";
|
||||
import {
|
||||
ATC_BFARM_SYSTEM,
|
||||
ATC_SYSTEM,
|
||||
RXNORM_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
patientRef,
|
||||
type FhirBuildOptions,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
|
||||
/**
|
||||
* Build a `medicationCodeableConcept` from a medication's free-text name
|
||||
* plus its optional user-asserted codes. ATC is emitted first (primary),
|
||||
* RxNorm second (secondary); both are omitted when NULL, collapsing to
|
||||
* exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code.
|
||||
* When `germanAtc` is set, the same ATC leaf code is also published under
|
||||
* the BfArM URI AFTER the WHO entry — additive, never reordering WHO.
|
||||
*/
|
||||
function medicationConcept(
|
||||
name: string,
|
||||
atcCode: string | null | undefined,
|
||||
rxNormCode: string | null | undefined,
|
||||
germanAtc: boolean,
|
||||
): FhirCodeableConcept {
|
||||
const coding: NonNullable<FhirCodeableConcept["coding"]> = [];
|
||||
if (atcCode) {
|
||||
coding.push({ system: ATC_SYSTEM, code: atcCode, display: name });
|
||||
if (germanAtc) {
|
||||
coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name });
|
||||
}
|
||||
}
|
||||
if (rxNormCode) {
|
||||
coding.push({ system: RXNORM_SYSTEM, code: rxNormCode });
|
||||
}
|
||||
return coding.length > 0 ? { coding, text: name } : { text: name };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.9.0 — the dose units HealthLog stores, normalised to their UCUM symbol.
|
||||
* The display `unit` is always the user's original string; the UCUM `code` is
|
||||
* set only for an unambiguous mapping so a consumer that resolves UCUM never
|
||||
* sees a guessed code. An unmapped unit keeps just the human-readable `unit`.
|
||||
*/
|
||||
const UCUM_DOSE_CODES: Record<string, string> = {
|
||||
mg: "mg",
|
||||
g: "g",
|
||||
mcg: "ug",
|
||||
µg: "ug",
|
||||
ug: "ug",
|
||||
ml: "mL",
|
||||
mL: "mL",
|
||||
};
|
||||
|
||||
function doseQuantity(
|
||||
value: number,
|
||||
unit: string,
|
||||
): { value: number; unit: string; system?: string; code?: string } {
|
||||
const ucum = UCUM_DOSE_CODES[unit];
|
||||
return ucum
|
||||
? { value, unit, system: UCUM_SYSTEM, code: ucum }
|
||||
: { value, unit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route of administration derived from the medication's delivery form,
|
||||
* carrying an additive SNOMED CT `coding` alongside the existing `.text`
|
||||
* anchor. HealthLog injections are subcutaneous (the injection-site picker
|
||||
* exists for the self-injection workflow), so `INJECTION` maps to the
|
||||
* subcutaneous route. Returns `undefined` for an unknown / absent form so no
|
||||
* empty route is emitted.
|
||||
*/
|
||||
const ROUTE_SNOMED: Record<string, { code: string; display: string }> = {
|
||||
ORAL: { code: "26643006", display: "Oral route" },
|
||||
INJECTION: { code: "34206005", display: "Subcutaneous route" },
|
||||
};
|
||||
|
||||
function routeConcept(
|
||||
deliveryForm: string | null,
|
||||
): FhirCodeableConcept | undefined {
|
||||
const text =
|
||||
deliveryForm === "ORAL"
|
||||
? "Oral"
|
||||
: deliveryForm === "INJECTION"
|
||||
? "Injection"
|
||||
: undefined;
|
||||
if (!text) return undefined;
|
||||
const snomed = ROUTE_SNOMED[deliveryForm as string];
|
||||
return snomed
|
||||
? {
|
||||
coding: [
|
||||
{ system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
|
||||
],
|
||||
text,
|
||||
}
|
||||
: { text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Administration body-site keyed on the raw `InjectionSite` enum value,
|
||||
* carrying an additive SNOMED CT body-region `coding` alongside the `.text`
|
||||
* anchor. The map collapses the eight enum members to three gross body-region
|
||||
* concepts (abdomen / thigh / upper arm); laterality (left/right) and the
|
||||
* abdominal quadrant are NOT lateralised SNOMED concepts here — they are
|
||||
* preserved verbatim in the human-readable `.text` (the raw enum value), so
|
||||
* no information is lost.
|
||||
*/
|
||||
const SITE_SNOMED: Record<string, { code: string; display: string }> = {
|
||||
ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" },
|
||||
ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" },
|
||||
ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" },
|
||||
ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" },
|
||||
THIGH_LEFT: { code: "68367000", display: "Thigh structure" },
|
||||
THIGH_RIGHT: { code: "68367000", display: "Thigh structure" },
|
||||
UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" },
|
||||
UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" },
|
||||
};
|
||||
|
||||
function siteConcept(injectionSite: string): FhirCodeableConcept {
|
||||
const snomed = SITE_SNOMED[injectionSite];
|
||||
// Preserve the full enum value (incl. laterality) as the readable anchor.
|
||||
const text = injectionSite;
|
||||
return snomed
|
||||
? {
|
||||
coding: [
|
||||
{ system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
|
||||
],
|
||||
text,
|
||||
}
|
||||
: { text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one `MedicationStatement` per active medication. Ids run `med-1..N`.
|
||||
*/
|
||||
export function medicationStatementsFromReportData(
|
||||
data: DoctorReportData,
|
||||
options: FhirBuildOptions = {},
|
||||
): FhirMedicationStatement[] {
|
||||
const germanAtc = options.germanAtc ?? false;
|
||||
const statements: FhirMedicationStatement[] = [];
|
||||
let medSeq = 0;
|
||||
for (const med of data.medications) {
|
||||
medSeq += 1;
|
||||
const stmt: FhirMedicationStatement = {
|
||||
resourceType: "MedicationStatement",
|
||||
id: `med-${medSeq}`,
|
||||
status: "active",
|
||||
medicationCodeableConcept: medicationConcept(
|
||||
med.name,
|
||||
med.atcCode,
|
||||
med.rxNormCode,
|
||||
germanAtc,
|
||||
),
|
||||
subject: patientRef,
|
||||
};
|
||||
if (med.dose) stmt.dosage = [{ text: med.dose }];
|
||||
statements.push(stmt);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one `MedicationAdministration` per acted intake: `completed` (taken)
|
||||
* or `not-done` (explicitly skipped). Pending / missed slots and soft-deleted
|
||||
* tombstones are excluded upstream by the aggregator. The concept reuses the
|
||||
* same ATC/RxNorm coding as the statement so each administration is
|
||||
* self-describing without resolving a reference (no `partOf` / `request`
|
||||
* coupling). A `dosage` is emitted ONLY when a structured `dose` Quantity is
|
||||
* available; a dosage with only `.text` would violate the R4 dose-or-rate
|
||||
* invariant. Ids run `medadmin-1..N`.
|
||||
*/
|
||||
export function medicationAdministrationsFromReportData(
|
||||
data: DoctorReportData,
|
||||
options: FhirBuildOptions = {},
|
||||
): FhirMedicationAdministration[] {
|
||||
const germanAtc = options.germanAtc ?? false;
|
||||
const administrations: FhirMedicationAdministration[] = [];
|
||||
let adminSeq = 0;
|
||||
for (const admin of data.medicationAdministrations ?? []) {
|
||||
adminSeq += 1;
|
||||
const resource: FhirMedicationAdministration = {
|
||||
resourceType: "MedicationAdministration",
|
||||
id: `medadmin-${adminSeq}`,
|
||||
status: admin.status,
|
||||
medicationCodeableConcept: medicationConcept(
|
||||
admin.medicationName,
|
||||
admin.atcCode,
|
||||
admin.rxNormCode,
|
||||
germanAtc,
|
||||
),
|
||||
subject: patientRef,
|
||||
effectiveDateTime: admin.effectiveAt,
|
||||
};
|
||||
|
||||
// Dosage: only when a structured dose Quantity exists. Carry the
|
||||
// free-text dose + route + site alongside it. Route and site each carry
|
||||
// an additive SNOMED coding plus the existing `.text` anchor.
|
||||
if (admin.dose) {
|
||||
const dosage: FhirDosage = {
|
||||
dose: doseQuantity(admin.dose.value, admin.dose.unit),
|
||||
};
|
||||
if (admin.doseText) dosage.text = admin.doseText;
|
||||
const route = routeConcept(admin.deliveryForm);
|
||||
if (route) dosage.route = route;
|
||||
if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite);
|
||||
resource.dosage = dosage;
|
||||
}
|
||||
|
||||
administrations.push(resource);
|
||||
}
|
||||
return administrations;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* The Observation orchestrator: one array, one continuous `obs-N` sequence,
|
||||
* in the canonical order — one latest reading per measurement type, the
|
||||
* blood-pressure panel, the computed BMI, glucose per context, the clinical
|
||||
* glucose panel, labs, medication adherence, the opt-in mood average, then the
|
||||
* descriptive wellness composites under `survey`.
|
||||
*
|
||||
* The per-domain blocks (glucose, labs) live in their own modules and draw ids
|
||||
* from the SAME allocator, so a document builder's references stay stable and a
|
||||
* `searchset` caller can filter the array by `category` / `code` without
|
||||
* re-numbering.
|
||||
*/
|
||||
import {
|
||||
adherenceRatePercent,
|
||||
type DoctorReportData,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import {
|
||||
LOINC_SYSTEM,
|
||||
MEASUREMENT_LOINC,
|
||||
BP_PANEL_LOINC,
|
||||
BP_SYS_LOINC,
|
||||
BP_DIA_LOINC,
|
||||
BP_UNIT,
|
||||
MEDICATION_ADHERENCE_LOINC,
|
||||
MOOD_LOINC,
|
||||
} from "@/lib/fhir/loinc-map";
|
||||
import { ucumQuantity } from "@/lib/fhir/ucum";
|
||||
import type { FhirObservation } from "@/lib/fhir/types";
|
||||
import {
|
||||
categoryConcept,
|
||||
codeableFromMapping,
|
||||
latestReading,
|
||||
observationIdSequence,
|
||||
patientRef,
|
||||
reportingPeriod,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
import {
|
||||
glucoseClinicalObservations,
|
||||
glucoseContextObservations,
|
||||
} from "@/lib/fhir/resources/glucose";
|
||||
import { labObservations } from "@/lib/fhir/resources/labs";
|
||||
|
||||
/** v1.10.0 — English display per persisted wellness-score type (FHIR
|
||||
* text-only concept; the score has no published LOINC term). */
|
||||
const WELLNESS_SCORE_DISPLAY: Record<string, string> = {
|
||||
RECOVERY_SCORE: "Recovery score",
|
||||
STRESS_SCORE: "Stress score",
|
||||
STRAIN_SCORE: "Strain score",
|
||||
};
|
||||
|
||||
/** One latest reading per single-value measurement type. */
|
||||
function measurementObservations(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const observations: FhirObservation[] = [];
|
||||
for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) {
|
||||
// BMI is emitted once by the computed-BMI block below (matching the PDF's
|
||||
// BMI line); skip the stored BODY_MASS_INDEX series here to avoid a
|
||||
// duplicate Observation.
|
||||
if (type === "BODY_MASS_INDEX") continue;
|
||||
const reading = latestReading(data, type);
|
||||
if (!reading) continue;
|
||||
// SLEEP_DURATION is stored in MINUTES; the iOS-locked UCUM unit is `h`, so
|
||||
// emit the value in hours to keep value and unit consistent. The PDF reads
|
||||
// the raw series independently and is unaffected.
|
||||
const value =
|
||||
type === "SLEEP_DURATION"
|
||||
? Math.round((reading.value / 60) * 100) / 100
|
||||
: reading.value;
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept(mapping.category)],
|
||||
code: codeableFromMapping(mapping),
|
||||
subject: patientRef,
|
||||
effectiveDateTime: reading.measuredAt,
|
||||
// The registry's display unit is only stamped as a UCUM `code` when it
|
||||
// IS one — `dB[A]` reads as decibels to a person and as nothing to a
|
||||
// UCUM parser.
|
||||
valueQuantity: ucumQuantity(value, mapping.unit),
|
||||
});
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
/** The blood-pressure panel, present only when both components have a reading. */
|
||||
function bloodPressureObservation(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const sys = latestReading(data, "BLOOD_PRESSURE_SYS");
|
||||
const dia = latestReading(data, "BLOOD_PRESSURE_DIA");
|
||||
if (!sys || !dia) return [];
|
||||
return [
|
||||
{
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("vital-signs")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: BP_PANEL_LOINC,
|
||||
display: "Blood pressure panel",
|
||||
},
|
||||
],
|
||||
text: "Blood pressure",
|
||||
},
|
||||
subject: patientRef,
|
||||
// Use the systolic reading's timestamp as the panel effective time.
|
||||
effectiveDateTime: sys.measuredAt,
|
||||
component: [
|
||||
{
|
||||
code: {
|
||||
coding: [
|
||||
{ system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" },
|
||||
],
|
||||
},
|
||||
valueQuantity: ucumQuantity(sys.value, BP_UNIT),
|
||||
},
|
||||
{
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: BP_DIA_LOINC,
|
||||
display: "Diastolic",
|
||||
},
|
||||
],
|
||||
},
|
||||
valueQuantity: ucumQuantity(dia.value, BP_UNIT),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The computed BMI, matching the PDF's BMI line. */
|
||||
function bmiObservation(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
if (data.bmi === null || data.bmi === undefined) return [];
|
||||
return [
|
||||
{
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("vital-signs")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: "39156-5",
|
||||
display: "Body mass index (BMI) [Ratio]",
|
||||
},
|
||||
],
|
||||
text: "Body mass index (BMI) [Ratio]",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: reportingPeriod(data),
|
||||
valueQuantity: ucumQuantity(data.bmi, "kg/m2"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** One adherence rate per medication with a scheduled dose in the window. */
|
||||
function adherenceObservations(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const observations: FhirObservation[] = [];
|
||||
const period = reportingPeriod(data);
|
||||
for (const [name, comp] of Object.entries(data.compliance)) {
|
||||
if (comp.total <= 0) continue;
|
||||
// Integer percent — the one canonical rounding the app card + PDF use, so
|
||||
// a clinician sees the same adherence figure on every surface.
|
||||
const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("activity")],
|
||||
code: {
|
||||
coding: [
|
||||
{
|
||||
system: LOINC_SYSTEM,
|
||||
code: MEDICATION_ADHERENCE_LOINC,
|
||||
display: "Medication adherence",
|
||||
},
|
||||
],
|
||||
text: `Medication adherence — ${name}`,
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: period,
|
||||
valueQuantity: ucumQuantity(rate, "%"),
|
||||
});
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
/** The opt-in mood average; absent when the toggle is off. */
|
||||
function moodObservation(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
if (!data.mood) return [];
|
||||
return [
|
||||
{
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("vital-signs")],
|
||||
code: {
|
||||
coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }],
|
||||
text: "Mood (average over period)",
|
||||
},
|
||||
subject: patientRef,
|
||||
effectivePeriod: reportingPeriod(data),
|
||||
valueQuantity: ucumQuantity(
|
||||
Math.round(data.mood.avg * 10) / 10,
|
||||
"{score}",
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.10.0 — the server-derived nightly scores (recovery / stress / strain).
|
||||
* They have no published LOINC term and are NOT clinical findings, so each is
|
||||
* emitted under the `survey` category with a text-only concept and an explicit
|
||||
* "descriptive, not a clinical assessment" note — a physician's FHIR viewer
|
||||
* never mistakes a band for a diagnosis. Absent when the aggregator emitted no
|
||||
* scores.
|
||||
*/
|
||||
function wellnessObservations(
|
||||
data: DoctorReportData,
|
||||
nextId: () => string,
|
||||
): FhirObservation[] {
|
||||
const observations: FhirObservation[] = [];
|
||||
for (const s of data.wellnessScores ?? []) {
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
status: "final",
|
||||
category: [categoryConcept("survey")],
|
||||
code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" },
|
||||
subject: patientRef,
|
||||
effectiveDateTime: s.latestAt,
|
||||
valueQuantity: ucumQuantity(s.latest, "{score}"),
|
||||
note: [
|
||||
{
|
||||
text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit every `Observation` from the aggregated report data, in the canonical
|
||||
* order.
|
||||
*/
|
||||
export function observationsFromReportData(
|
||||
data: DoctorReportData,
|
||||
): FhirObservation[] {
|
||||
const nextId = observationIdSequence();
|
||||
return [
|
||||
...measurementObservations(data, nextId),
|
||||
...bloodPressureObservation(data, nextId),
|
||||
...bmiObservation(data, nextId),
|
||||
...glucoseContextObservations(data, nextId),
|
||||
...glucoseClinicalObservations(data, nextId),
|
||||
...labObservations(data, nextId),
|
||||
...adherenceObservations(data, nextId),
|
||||
...moodObservation(data, nextId),
|
||||
...wellnessObservations(data, nextId),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The `Patient` resource: display name, administrative gender, birth date and
|
||||
* the KVNR identifier. Every absent field collapses its slot.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { FhirPatient } from "@/lib/fhir/types";
|
||||
import {
|
||||
KVNR_SYSTEM,
|
||||
PATIENT_RESOURCE_ID,
|
||||
type FhirPatientIdentity,
|
||||
} from "@/lib/fhir/resources/common";
|
||||
|
||||
/**
|
||||
* Stored gender → the R4 `administrative-gender` code.
|
||||
*
|
||||
* The stored value is the free-text projection of the app's own three-member
|
||||
* enum. The mapping is exhaustive over it and closed to everything else: an
|
||||
* unrecognised string omits the element rather than picking a side, because a
|
||||
* clinical document that names a gender the person did not record states
|
||||
* something false about them.
|
||||
*
|
||||
* `unknown` is deliberately never emitted. In FHIR it means "asked, and the
|
||||
* answer is not known" — a positive claim about an interaction that did not
|
||||
* happen. An unrecorded field is honestly absent instead.
|
||||
*/
|
||||
const ADMINISTRATIVE_GENDER: Record<string, FhirPatient["gender"]> = {
|
||||
MALE: "male",
|
||||
FEMALE: "female",
|
||||
OTHER: "other",
|
||||
};
|
||||
|
||||
/** The R4 gender code for a stored value, or `null` to omit the element. */
|
||||
export function administrativeGender(
|
||||
stored: string | null | undefined,
|
||||
): FhirPatient["gender"] | null {
|
||||
if (!stored) return null;
|
||||
return ADMINISTRATIVE_GENDER[stored] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the `Patient` resource from the aggregated report data + decrypted
|
||||
* identity.
|
||||
*/
|
||||
export function patientResource(
|
||||
data: DoctorReportData,
|
||||
identity: FhirPatientIdentity,
|
||||
): FhirPatient {
|
||||
const patient: FhirPatient = {
|
||||
resourceType: "Patient",
|
||||
id: PATIENT_RESOURCE_ID,
|
||||
};
|
||||
const displayName = data.patient.fullName ?? data.patient.username ?? null;
|
||||
if (displayName) patient.name = [{ text: displayName }];
|
||||
const gender = administrativeGender(data.patient.gender);
|
||||
if (gender) patient.gender = gender;
|
||||
if (data.patient.dateOfBirth) {
|
||||
patient.birthDate = data.patient.dateOfBirth.slice(0, 10);
|
||||
}
|
||||
if (identity.insuranceNumber) {
|
||||
patient.identifier = [
|
||||
{ system: KVNR_SYSTEM, value: identity.insuranceNumber },
|
||||
];
|
||||
}
|
||||
return patient;
|
||||
}
|
||||
+89
-10
@@ -15,14 +15,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
normaliseDateRange,
|
||||
type DoctorReportData,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
|
||||
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import type {
|
||||
FhirBundleEntry,
|
||||
FhirBundleLink,
|
||||
@@ -43,15 +46,19 @@ export const FHIR_READ_SCOPE = "fhir:read";
|
||||
*/
|
||||
export const FHIR_REST_RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Coverage",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The whole-record operation the REST face exposes (`GET /api/fhir/$everything`),
|
||||
* returning the existing `type: "document"` Bundle. Surfaced in discovery so a
|
||||
* client knows the snapshot pull exists alongside the per-resource searches.
|
||||
* The whole-record operation the REST face exposes
|
||||
* (`GET /api/fhir/Patient/$everything`). It returns a `type: "searchset"`
|
||||
* Bundle carrying every resource the document export carries, entry-for-entry,
|
||||
* under the same `urn:uuid` identities — so a reference between two of them
|
||||
* still resolves inside the response. Surfaced in discovery so a client knows
|
||||
* the snapshot pull exists alongside the per-resource searches.
|
||||
*/
|
||||
export const FHIR_EVERYTHING_OPERATION = "$everything";
|
||||
|
||||
@@ -127,6 +134,27 @@ export function searchsetResponse(
|
||||
const entry: FhirBundleEntry[] = page.map((resource) => ({
|
||||
fullUrl: `${requestUrl.origin}/api/fhir/${resource.resourceType}/${resource.id}`,
|
||||
resource,
|
||||
}));
|
||||
return searchsetResponseFromEntries(requestUrl, entry, total, count, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same `searchset` wrapper for a page whose entry identities are already
|
||||
* decided. `$everything` uses it to hand back the document bundle's own
|
||||
* `urn:uuid` fullUrls: those resources are not all individually addressable
|
||||
* (only the routed types have a `/{type}/{id}`), and minting a REST URL for a
|
||||
* Composition or an Encounter would advertise an address that 404s.
|
||||
*/
|
||||
export function searchsetResponseFromEntries(
|
||||
requestUrl: URL,
|
||||
page: ReadonlyArray<Omit<FhirBundleEntry, "search">>,
|
||||
total: number,
|
||||
count: number,
|
||||
offset: number,
|
||||
): NextResponse {
|
||||
const entry: FhirBundleEntry[] = page.map((e) => ({
|
||||
fullUrl: e.fullUrl,
|
||||
resource: e.resource,
|
||||
search: { mode: "match" },
|
||||
}));
|
||||
|
||||
@@ -145,6 +173,23 @@ export function searchsetResponse(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a `GET /{Type}/{id}` read: the resource itself when the caller's own
|
||||
* record holds that id, an R4 `not-found` OperationOutcome otherwise. There is
|
||||
* no cross-user lookup — `candidates` is already the caller's set, so an id
|
||||
* belonging to someone else is indistinguishable from one that never existed.
|
||||
*/
|
||||
export function readResponse(
|
||||
candidates: readonly FhirResource[],
|
||||
id: string,
|
||||
): NextResponse {
|
||||
const match = candidates.find((resource) => resource.id === id);
|
||||
if (!match) {
|
||||
return operationOutcome(404, "not-found", "No such resource");
|
||||
}
|
||||
return fhirJsonResponse(match);
|
||||
}
|
||||
|
||||
/** Return a raw FHIR resource (e.g. CapabilityStatement) as a FHIR response. */
|
||||
export function fhirJsonResponse(body: unknown): NextResponse {
|
||||
return new NextResponse(JSON.stringify(body), {
|
||||
@@ -176,9 +221,11 @@ export function operationOutcome(
|
||||
|
||||
/**
|
||||
* Resolve everything an emitter needs for a caller: the aggregated report data
|
||||
* over the default reporting window, plus the decrypted KVNR identity and the
|
||||
* BfArM-ATC flag (derived from the user's locale). Centralised so each
|
||||
* resource route is a one-liner over the shared emitters.
|
||||
* over the default reporting window, the decrypted KVNR identity, the
|
||||
* BfArM-ATC flag (derived from the user's locale), and the structured records
|
||||
* (allergies + family history) that live outside the time-windowed
|
||||
* aggregation. Centralised so each resource route is a one-liner over the
|
||||
* shared emitters.
|
||||
*
|
||||
* The window matches the document export's default (`normaliseDateRange`
|
||||
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
|
||||
@@ -197,6 +244,7 @@ export async function loadFhirContext(userId: string): Promise<{
|
||||
data: DoctorReportData;
|
||||
identity: { insuranceNumber: string | null };
|
||||
germanAtc: boolean;
|
||||
records: FhirRecordInputs;
|
||||
}> {
|
||||
if (!(await isModuleEnabled(userId, "doctorReport"))) {
|
||||
throw new Error(
|
||||
@@ -205,17 +253,40 @@ export async function loadFhirContext(userId: string): Promise<{
|
||||
);
|
||||
}
|
||||
|
||||
// The REST face cannot ask a human, so it replays the owner's SAVED
|
||||
// selection — the same object reviewed in Settings, no more and no less. An
|
||||
// account that never saved one resolves to the empty selection and this face
|
||||
// serves nothing, which is the honest answer to "give me a record the owner
|
||||
// never scoped". It used to assemble everything at server defaults.
|
||||
const selection = await resolveSavedSelection(userId);
|
||||
|
||||
const range = normaliseDateRange(undefined);
|
||||
const [data, userRow] = await Promise.all([
|
||||
collectDoctorReportData(userId, range, {}),
|
||||
const [data, userRow, allergyRows, familyHistoryRows] = await Promise.all([
|
||||
collectDoctorReportData(userId, range, selection),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { insuranceNumberEncrypted: true, locale: true },
|
||||
}),
|
||||
// The structured records are always-available reference data (not
|
||||
// time-windowed), so they ride alongside the report rather than through
|
||||
// the aggregator — the same split `/api/export/health-record` uses.
|
||||
// Owner-scoped, live rows only.
|
||||
selection.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
selection.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
let insuranceNumber: string | null = null;
|
||||
if (userRow?.insuranceNumberEncrypted) {
|
||||
if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
@@ -227,5 +298,13 @@ export async function loadFhirContext(userId: string): Promise<{
|
||||
userRow?.locale ?? "",
|
||||
);
|
||||
|
||||
return { data, identity: { insuranceNumber }, germanAtc };
|
||||
return {
|
||||
data,
|
||||
identity: { insuranceNumber },
|
||||
germanAtc,
|
||||
records: {
|
||||
allergies: allergyRows.map(toAllergyDTO),
|
||||
familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
|
||||
},
|
||||
};
|
||||
}
|
||||
+51
-4
@@ -24,8 +24,15 @@ export interface FhirReference {
|
||||
display?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* R4 `Identifier`. `system` is REQUIRED here even though the spec marks it
|
||||
* `0..1`: a bare value with no namespace is not resolvable by a receiver, and
|
||||
* every identifier HealthLog emits (KVNR, IKNR, bundle id) has a real
|
||||
* namespace. Requiring it is what stops a namespace-less identifier from
|
||||
* type-checking.
|
||||
*/
|
||||
export interface FhirIdentifier {
|
||||
system?: string;
|
||||
system: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -76,6 +83,14 @@ export interface FhirObservation {
|
||||
code: FhirCodeableConcept;
|
||||
subject: FhirReference;
|
||||
effectiveDateTime?: string;
|
||||
/**
|
||||
* R4 `Observation.effective[x]` as a period. Carries the reporting window
|
||||
* for an aggregate that describes a SPAN rather than an instant (a mean, a
|
||||
* time-in-range share, an adherence rate, a period average). Pinning such a
|
||||
* value to a single `effectiveDateTime` asserts an instant reading that was
|
||||
* never taken.
|
||||
*/
|
||||
effectivePeriod?: FhirPeriod;
|
||||
valueQuantity?: FhirQuantity;
|
||||
valueInteger?: number;
|
||||
valueString?: string;
|
||||
@@ -177,8 +192,13 @@ export interface FhirCoverage {
|
||||
beneficiary: FhirReference;
|
||||
/** German KVNR (the subscriber's member id), when present. */
|
||||
subscriberId?: string;
|
||||
/** Insurer(s) — Reference to the contained Organization. */
|
||||
payor?: FhirReference[];
|
||||
/**
|
||||
* Insurer(s) — Reference to the contained Organization. R4 marks this `1..*`;
|
||||
* required here so a payor-less Coverage cannot be constructed. A record that
|
||||
* knows only a member id emits no Coverage at all (the member id rides
|
||||
* `Patient.identifier`).
|
||||
*/
|
||||
payor: FhirReference[];
|
||||
}
|
||||
|
||||
export interface FhirCompositionSection {
|
||||
@@ -289,8 +309,24 @@ export interface FhirFamilyMemberHistory {
|
||||
note?: FhirAnnotation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* R4 `Device` — the software that authored the document. A `Composition.author`
|
||||
* pointing at a Device that is not in the bundle is a dangling reference, so
|
||||
* the document carries the Device it names. No serial number, no identifier,
|
||||
* no owner: the only honest facts are the software name, its maker and the
|
||||
* running version.
|
||||
*/
|
||||
export interface FhirDevice {
|
||||
resourceType: "Device";
|
||||
id: string;
|
||||
manufacturer?: string;
|
||||
deviceName?: { name: string; type: "manufacturer-name" }[];
|
||||
version?: { value: string }[];
|
||||
}
|
||||
|
||||
export type FhirResource =
|
||||
| FhirComposition
|
||||
| FhirDevice
|
||||
| FhirPatient
|
||||
| FhirCoverage
|
||||
| FhirObservation
|
||||
@@ -303,7 +339,12 @@ export type FhirResource =
|
||||
| FhirDiagnosticReport;
|
||||
|
||||
export interface FhirBundleEntry {
|
||||
fullUrl?: string;
|
||||
/**
|
||||
* Absolute URL identifying the entry. Required: `bdl-7` makes it the only
|
||||
* thing an intra-bundle reference can resolve against, and an entry without
|
||||
* one is unreachable from the Composition that names it.
|
||||
*/
|
||||
fullUrl: string;
|
||||
resource: FhirResource;
|
||||
/** v1.11.0 — `searchset` per-entry search metadata (`mode: "match"`). */
|
||||
search?: { mode: "match" | "include" | "outcome" };
|
||||
@@ -312,6 +353,12 @@ export interface FhirBundleEntry {
|
||||
export interface FhirBundle {
|
||||
resourceType: "Bundle";
|
||||
type: "document";
|
||||
/**
|
||||
* `bdl-9` — a document Bundle SHALL carry a persistent identifier with a
|
||||
* system and a value. Minted fresh per build: two exports of the same
|
||||
* record are two documents.
|
||||
*/
|
||||
identifier: FhirIdentifier;
|
||||
timestamp: string;
|
||||
entry: FhirBundleEntry[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The closed set of UCUM expressions HealthLog is allowed to stamp as a coded
|
||||
* `Quantity.code`.
|
||||
*
|
||||
* A FHIR `Quantity` that carries `system: http://unitsofmeasure.org` promises
|
||||
* that `code` is a valid UCUM expression a receiver can parse and convert.
|
||||
* Copying a display unit into that slot breaks the promise the moment a
|
||||
* display string is not UCUM — the registry's `dB[A]` is exactly that case:
|
||||
* a readable label for A-weighted decibels, not a UCUM term.
|
||||
*
|
||||
* So the rule here is the one the lab path already follows: the human-readable
|
||||
* `unit` is ALWAYS emitted, and `system` + `code` are added only for a token in
|
||||
* this list. An unlisted unit is still conformant — it is just uncoded, which
|
||||
* is the honest statement.
|
||||
*
|
||||
* Membership is asserted, not guessed: every entry is either a UCUM base /
|
||||
* derived expression or an annotation (`{…}`, which UCUM defines as a
|
||||
* dimensionless comment on the unity `1`). Adding a unit means checking it
|
||||
* against the UCUM specification first.
|
||||
*/
|
||||
|
||||
import { UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
|
||||
|
||||
/**
|
||||
* Validated UCUM expressions. Grouped by where they enter the export so a
|
||||
* future signal's unit is checked against the group it belongs to.
|
||||
*/
|
||||
const UCUM_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
// Dimensionless + annotations
|
||||
"1",
|
||||
"%",
|
||||
"{score}",
|
||||
"{count}",
|
||||
"{steps}",
|
||||
"{flights}",
|
||||
"{falls}",
|
||||
// Mass / length / ratio
|
||||
"kg",
|
||||
"g",
|
||||
"mg",
|
||||
"ug",
|
||||
"cm",
|
||||
"m",
|
||||
"kg/m2",
|
||||
// Time
|
||||
"ms",
|
||||
"s",
|
||||
"min",
|
||||
"h",
|
||||
"d",
|
||||
"a",
|
||||
// Rates / speed / energy
|
||||
"/min",
|
||||
"m/s",
|
||||
"kcal",
|
||||
"mL",
|
||||
"mL/min/kg",
|
||||
// Clinical
|
||||
"Cel",
|
||||
"mm[Hg]",
|
||||
"mg/dL",
|
||||
"mmol/L",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when `unit` is a UCUM expression this exporter has validated. Anything
|
||||
* else — a display label, a locale string, an unchecked new unit — is false,
|
||||
* and its Quantity goes out with `unit` alone.
|
||||
*/
|
||||
export function isUcumCode(unit: string | null | undefined): boolean {
|
||||
return typeof unit === "string" && UCUM_ALLOWLIST.has(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `Quantity` from a DISPLAY unit — the string the registry or the user
|
||||
* supplies, which is only sometimes a UCUM expression. It is emitted as `unit`
|
||||
* either way; `system` + `code` follow only when the string validates, and
|
||||
* they appear together or not at all (a `system` without a `code` names a code
|
||||
* system nothing in the resource uses).
|
||||
*
|
||||
* The curated paths do NOT go through here: `lab-loinc`'s analyte map and the
|
||||
* dose-unit table already resolve a canonical symbol, and that resolution is
|
||||
* their validation.
|
||||
*/
|
||||
export function ucumQuantity(
|
||||
value: number,
|
||||
unit: string,
|
||||
): { value: number; unit: string; system?: string; code?: string } {
|
||||
return isUcumCode(unit)
|
||||
? { value, unit, system: UCUM_SYSTEM, code: unit }
|
||||
: { value, unit };
|
||||
}
|
||||
@@ -129,9 +129,12 @@ describe("doctor_visit_summary — grounding + structure", () => {
|
||||
const text = res.messages[0].content.text;
|
||||
|
||||
// The window threads through to the data path (trailing 90 days).
|
||||
// Third argument: the owner's SAVED selection, replayed. This surface
|
||||
// cannot ask a human, so it never invents a scope.
|
||||
expect(collectDoctorReportData).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ days: 90 }),
|
||||
expect.objectContaining({ version: 2 }),
|
||||
);
|
||||
|
||||
const data = parseData(text);
|
||||
|
||||
@@ -227,9 +227,12 @@ describe("healthlog://report/doctor-visit", () => {
|
||||
CTX,
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
// Third argument: the owner's SAVED selection, replayed. This surface
|
||||
// cannot ask a human, so it never invents a scope.
|
||||
expect(collectDoctorReportData).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ days: 90 }),
|
||||
expect.objectContaining({ version: 2 }),
|
||||
);
|
||||
expect(result.present).toBe(true);
|
||||
expect(Array.isArray(result.vitals)).toBe(true);
|
||||
@@ -247,9 +250,12 @@ describe("healthlog://report/doctor-visit", () => {
|
||||
} as never);
|
||||
|
||||
await template("doctor-visit-windowed").read(CTX, { window: "last30days" });
|
||||
// Third argument: the owner's SAVED selection, replayed. This surface
|
||||
// cannot ask a human, so it never invents a scope.
|
||||
expect(collectDoctorReportData).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ days: 30 }),
|
||||
expect.objectContaining({ version: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,9 +233,12 @@ describe("healthlog://report/doctor-visit", () => {
|
||||
CTX,
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
// Third argument: the owner's SAVED selection, replayed. This surface
|
||||
// cannot ask a human, so it never invents a scope.
|
||||
expect(collectDoctorReportData).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ days: 90 }),
|
||||
expect.objectContaining({ version: 2 }),
|
||||
);
|
||||
expect(result.present).toBe(true);
|
||||
expect(result).toHaveProperty("vitals");
|
||||
|
||||
@@ -26,6 +26,16 @@ const shareLinkSummary = z.object({
|
||||
// v1.28 — how many documents this share carries. Never the ids, never the
|
||||
// bytes; the share serve route is the only decrypt path.
|
||||
documentCount: z.number(),
|
||||
// Whether this link's frozen scope predates the selection model. Every such
|
||||
// link was revoked on upgrade; the sharing list surfaces this so the owner
|
||||
// knows to re-mint rather than wondering why a working link stopped.
|
||||
needsReselection: z.boolean(),
|
||||
// RETIRED, response-only, serving the constants `false` and `[]`. They
|
||||
// configured a FHIR face that was never built; the request refuses them and
|
||||
// their columns are gone. They stay on the response until the native client
|
||||
// confirms it does not decode them.
|
||||
allowFhirApi: z.boolean(),
|
||||
resourceTypes: z.array(z.string()),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
revokedAt: z.string().nullable(),
|
||||
@@ -72,7 +82,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["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:<userId>` rate bucket (10/h). Strict validation: unknown keys 422.",
|
||||
"Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -104,7 +114,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Export"],
|
||||
summary: "Create a clinician share link (v1.11.0)",
|
||||
description:
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -244,7 +254,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 CapabilityStatement (v1.11.0)",
|
||||
description:
|
||||
"Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass).",
|
||||
"Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "CapabilityStatement (application/fhir+json).",
|
||||
@@ -358,12 +368,187 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/$everything": {
|
||||
"/api/fhir/Coverage": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 Coverage search",
|
||||
description:
|
||||
"Read-only `searchset` Bundle of the caller's own Coverage resource, when an insurer is recorded and the insurance leaf is in the saved selection. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
_count: z.coerce.number().optional(),
|
||||
_offset: z.coerce.number().optional(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "searchset Bundle (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/Patient/{id}": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 Patient read",
|
||||
description:
|
||||
"Read-only `Patient` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resource (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "OperationOutcome (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/Coverage/{id}": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 Coverage read",
|
||||
description:
|
||||
"Read-only `Coverage` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resource (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "OperationOutcome (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/Observation/{id}": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 Observation read",
|
||||
description:
|
||||
"Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resource (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "OperationOutcome (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/MedicationStatement/{id}": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 MedicationStatement read",
|
||||
description:
|
||||
"Read-only `MedicationStatement` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resource (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "OperationOutcome (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/MedicationAdministration/{id}": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 MedicationAdministration read",
|
||||
description:
|
||||
"Read-only `MedicationAdministration` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resource (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "OperationOutcome (application/fhir+json).",
|
||||
content: {
|
||||
"application/fhir+json": {
|
||||
schema: z.string().meta({ format: "binary" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/Patient/$everything": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
summary: "FHIR R4 $everything (v1.11.0)",
|
||||
description:
|
||||
"Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
|
||||
"Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
_count: z.coerce.number().optional(),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* The catalogue's own invariants — the ones the compiler cannot state.
|
||||
*
|
||||
* The two `Record` types already force a group decision for every measurement
|
||||
* type and every structured leaf. What they cannot force is that each leaf has
|
||||
* a label a human can read, that the label resolves in the shipped bundle, and
|
||||
* that the fenced tier stays last. A leaf with no label renders as a raw enum
|
||||
* string on a clinical document.
|
||||
*
|
||||
* Mutation checks:
|
||||
* - drop `PAIN_NRS` from `MEASUREMENT_LEAF_GROUP` → typecheck fails first,
|
||||
* and with the type widened the count assertion goes red.
|
||||
* - point `STRUCTURED_LEAF_LABEL_KEYS.MOOD` at a key that is not in the
|
||||
* bundle → "resolves a label for every leaf" goes red naming MOOD.
|
||||
* - move `"sensitive"` out of last place in `REPORT_GROUP_ORDER` → the order
|
||||
* assertion goes red.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
MEASUREMENT_LEAF_IDS,
|
||||
REPORT_GROUPS,
|
||||
REPORT_GROUP_LABEL_KEYS,
|
||||
REPORT_GROUP_ORDER,
|
||||
SENSITIVE_LEAF_IDS,
|
||||
STRUCTURED_LEAF_IDS,
|
||||
leafLabelKey,
|
||||
} from "../catalogue";
|
||||
import { STANDARD_TEMPLATE_LEAVES } from "../template";
|
||||
|
||||
const EN = JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, "..", "..", "..", "..", "messages", "en.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
function resolves(key: string): boolean {
|
||||
let node: unknown = EN;
|
||||
for (const part of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null) return false;
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof node === "string" && node.length > 0;
|
||||
}
|
||||
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(13);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(90);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
const unlabelled = ALL_LEAF_IDS.filter(
|
||||
(leaf) => !resolves(leafLabelKey(leaf)),
|
||||
);
|
||||
expect(
|
||||
unlabelled,
|
||||
"these leaves have no label in messages/en.json and would render as a " +
|
||||
"raw enum string on a clinical document",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves a label for every group", () => {
|
||||
const unlabelled = REPORT_GROUP_ORDER.filter(
|
||||
(group) => !resolves(REPORT_GROUP_LABEL_KEYS[group]),
|
||||
);
|
||||
expect(unlabelled).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders the groups the way the panel and the PDF read them", () => {
|
||||
expect(REPORT_GROUPS.map((g) => g.id)).toEqual([...REPORT_GROUP_ORDER]);
|
||||
// The fenced tier is last, so it sits below every group control rather
|
||||
// than among them.
|
||||
expect(REPORT_GROUP_ORDER[REPORT_GROUP_ORDER.length - 1]).toBe("sensitive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("template purity", () => {
|
||||
it("keeps the shipped template clear of the fenced tier", () => {
|
||||
const overlap = STANDARD_TEMPLATE_LEAVES.filter((leaf) =>
|
||||
SENSITIVE_LEAF_IDS.includes(leaf),
|
||||
);
|
||||
expect(
|
||||
overlap,
|
||||
"the shipped template must never preselect a fenced leaf",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("names every template leaf in the catalogue", () => {
|
||||
const unknown = STANDARD_TEMPLATE_LEAVES.filter(
|
||||
(leaf) => !ALL_LEAF_IDS.includes(leaf),
|
||||
);
|
||||
expect(unknown).toEqual([]);
|
||||
});
|
||||
|
||||
it("fences exactly the seven leaves the design names", () => {
|
||||
expect([...SENSITIVE_LEAF_IDS].sort()).toEqual(
|
||||
[
|
||||
"CYCLE",
|
||||
"FAMILY_HISTORY",
|
||||
"GAD7_SCORE",
|
||||
"MOOD",
|
||||
"PHQ9_SCORE",
|
||||
"SCI_SCORE",
|
||||
"WHO5_SCORE",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The picker's pure state: derived group states, the group toggle, and the
|
||||
* scope line.
|
||||
*
|
||||
* Two properties are load-bearing beyond the panel. A group control must never
|
||||
* reach a fenced leaf, and the scope line must restate every fenced inclusion
|
||||
* by name rather than folding it into a count — that line is the consent check.
|
||||
*
|
||||
* Mutation checks:
|
||||
* - remove the `SENSITIVE_GROUP_ID` early return in `toggleGroup` → "no group
|
||||
* control can reach a fenced leaf" goes red.
|
||||
* - drop the `sensitiveLabelKeys.push(...)` branch in `scopeSummary` → "the
|
||||
* scope line names every fenced inclusion" goes red.
|
||||
* - make `groupCheckState` return "all" when some leaves are on → "a partly
|
||||
* selected group reads as mixed" goes red.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
groupCheckState,
|
||||
groupCount,
|
||||
scopeSummary,
|
||||
toggleGroup,
|
||||
toggleLeaf,
|
||||
} from "../panel-state";
|
||||
import { REPORT_GROUPS, type ReportLeafId } from "../catalogue";
|
||||
|
||||
const vitals = REPORT_GROUPS.find((g) => g.id === "vitals")!;
|
||||
const sensitive = REPORT_GROUPS.find((g) => g.id === "sensitive")!;
|
||||
|
||||
describe("scope picker state", () => {
|
||||
it("derives none / mixed / all from the leaves alone", () => {
|
||||
expect(groupCheckState("vitals", new Set())).toBe("none");
|
||||
expect(groupCheckState("vitals", new Set([vitals.leaves[0]]))).toBe(
|
||||
"mixed",
|
||||
);
|
||||
expect(groupCheckState("vitals", new Set(vitals.leaves))).toBe("all");
|
||||
});
|
||||
|
||||
it("counts what is on against what exists", () => {
|
||||
const count = groupCount("vitals", new Set([vitals.leaves[0]]));
|
||||
expect(count).toEqual({ on: 1, total: vitals.leaves.length });
|
||||
});
|
||||
|
||||
it("turns a mixed group fully on, and a full group fully off", () => {
|
||||
const partial = new Set<ReportLeafId>([vitals.leaves[0]]);
|
||||
const on = toggleGroup(partial, "vitals");
|
||||
expect(groupCheckState("vitals", on)).toBe("all");
|
||||
const off = toggleGroup(on, "vitals");
|
||||
expect(groupCheckState("vitals", off)).toBe("none");
|
||||
});
|
||||
|
||||
it("restores the previous pattern when a group comes back on", () => {
|
||||
const original = new Set<ReportLeafId>([
|
||||
vitals.leaves[0],
|
||||
vitals.leaves[1],
|
||||
]);
|
||||
const off = toggleGroup(original, "vitals", original);
|
||||
const back = toggleGroup(off, "vitals", original);
|
||||
expect([...back].sort()).toEqual([...original].sort());
|
||||
});
|
||||
|
||||
it("lets no group control reach a fenced leaf", () => {
|
||||
const before = new Set<ReportLeafId>();
|
||||
const after = toggleGroup(before, "sensitive");
|
||||
expect([...after]).toEqual([]);
|
||||
for (const leaf of sensitive.leaves) {
|
||||
expect(after.has(leaf)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("still lets a single fenced leaf be chosen one at a time", () => {
|
||||
const one = toggleLeaf(new Set<ReportLeafId>(), "MOOD");
|
||||
expect(one.has("MOOD")).toBe(true);
|
||||
expect(one.has("CYCLE")).toBe(false);
|
||||
});
|
||||
|
||||
it("names every fenced inclusion in the scope line", () => {
|
||||
const summary = scopeSummary(new Set<ReportLeafId>(["WEIGHT", "MOOD"]));
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.groups).toBe(2);
|
||||
expect(summary.sensitiveLabelKeys).toEqual(["reportSelection.leafMood"]);
|
||||
});
|
||||
|
||||
it("says nothing is chosen when nothing is", () => {
|
||||
const summary = scopeSummary(new Set());
|
||||
expect(summary).toEqual({ total: 0, groups: 0, sensitiveLabelKeys: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* The selection type's contract: what each of the three minting functions does
|
||||
* with input it does not recognise, and that absence never becomes inclusion.
|
||||
*
|
||||
* Mutation checks:
|
||||
* - make `selectionFromStoredBlob` fall back to `STANDARD_TEMPLATE_LEAVES`
|
||||
* instead of the empty selection → "an unreadable blob resolves to
|
||||
* nothing" goes red.
|
||||
* - make `selectionFromRequest` drop unknown ids instead of rejecting →
|
||||
* "a request naming an id this build does not know is refused" goes red.
|
||||
* - drop the `.filter(isReportLeafId)` in `selectionFromStoredBlob` → "a
|
||||
* stored blob naming a retired leaf drops it" goes red.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
EMPTY_REPORT_SELECTION,
|
||||
isEmptySelection,
|
||||
selectionFromLeaves,
|
||||
selectionFromRequest,
|
||||
selectionFromStoredBlob,
|
||||
selectionToBlob,
|
||||
} from "../selection";
|
||||
import { ALL_LEAF_IDS } from "../catalogue";
|
||||
|
||||
describe("report selection", () => {
|
||||
it("treats absence as exclusion", () => {
|
||||
const selection = selectionFromLeaves(["WEIGHT"]);
|
||||
expect(selection.has("WEIGHT")).toBe(true);
|
||||
// Everything else in the catalogue is out, without anyone saying so.
|
||||
for (const leaf of ALL_LEAF_IDS) {
|
||||
if (leaf === "WEIGHT") continue;
|
||||
expect(selection.has(leaf)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("orders leaves by the catalogue, not by the caller", () => {
|
||||
const selection = selectionFromLeaves(["LAB_RESULTS", "PULSE", "WEIGHT"]);
|
||||
const expected = ALL_LEAF_IDS.filter((leaf) =>
|
||||
["LAB_RESULTS", "PULSE", "WEIGHT"].includes(leaf),
|
||||
);
|
||||
expect(selection.leaves).toEqual(expected);
|
||||
});
|
||||
|
||||
it("refuses a request naming an id this build does not know", () => {
|
||||
const result = selectionFromRequest({
|
||||
v: 2,
|
||||
leaves: ["WEIGHT", "SOMETHING_NEW"],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.unknownLeaves).toEqual(["SOMETHING_NEW"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a request naming only known ids", () => {
|
||||
const result = selectionFromRequest({ v: 2, leaves: ["WEIGHT"] });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.selection.leaves).toEqual(["WEIGHT"]);
|
||||
});
|
||||
|
||||
it("drops a retired leaf from a stored blob rather than failing", () => {
|
||||
const selection = selectionFromStoredBlob({
|
||||
v: 2,
|
||||
leaves: ["WEIGHT", "SOMETHING_RETIRED"],
|
||||
});
|
||||
expect(selection.leaves).toEqual(["WEIGHT"]);
|
||||
});
|
||||
|
||||
it("resolves an unreadable blob to nothing, never to a default", () => {
|
||||
for (const raw of [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ v: 1, leaves: ["WEIGHT"] },
|
||||
{ bp: true, weight: true, pulse: true },
|
||||
"WEIGHT",
|
||||
[],
|
||||
]) {
|
||||
const selection = selectionFromStoredBlob(raw);
|
||||
expect(isEmptySelection(selection), `blob ${JSON.stringify(raw)}`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips through the stored shape", () => {
|
||||
const selection = selectionFromLeaves(["WEIGHT", "LAB_RESULTS"]);
|
||||
const blob = selectionToBlob(selection);
|
||||
expect(selectionFromStoredBlob(blob).leaves).toEqual(selection.leaves);
|
||||
});
|
||||
|
||||
it("has an empty selection that admits nothing", () => {
|
||||
expect(isEmptySelection(EMPTY_REPORT_SELECTION)).toBe(true);
|
||||
for (const leaf of ALL_LEAF_IDS) {
|
||||
expect(EMPTY_REPORT_SELECTION.has(leaf)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,11 @@ export const savedReportProfileSchema = reportSelectionSchema.extend({
|
||||
|
||||
export type SavedReportProfile = z.infer<typeof savedReportProfileSchema>;
|
||||
|
||||
/** What the panel seeds from when the account has never saved a profile. */
|
||||
/**
|
||||
* The render choices the panel starts from when the account has never saved a
|
||||
* profile. Presentation only — this is deliberately NOT a scope fallback;
|
||||
* there is no such thing here, because a scope nobody chose is the defect.
|
||||
*/
|
||||
export const SAVED_PROFILE_FALLBACK = {
|
||||
format: "pdf",
|
||||
rangeDays: 90,
|
||||
|
||||
@@ -250,5 +250,17 @@
|
||||
"display": "Waist to height ratio",
|
||||
"unit": "1",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"WHO5_SCORE": {
|
||||
"loinc": null,
|
||||
"display": "WHO-5 well-being index total score",
|
||||
"unit": "{score}",
|
||||
"category": "survey"
|
||||
},
|
||||
"SCI_SCORE": {
|
||||
"loinc": null,
|
||||
"display": "Sleep Condition Indicator total score",
|
||||
"unit": "{score}",
|
||||
"category": "survey"
|
||||
}
|
||||
}
|
||||
@@ -1275,9 +1275,12 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
// ── v1.27.9 — WHO-5 well-being + Sleep Condition Indicator totals ───────
|
||||
// Same privacy posture as PHQ-9 / GAD-7 (off Coach snapshot + MCP; item
|
||||
// content never rides a Measurement row). Direction inverts: HIGHER is
|
||||
// better for both. No verified total-score LOINC exists for either, so no
|
||||
// FHIR coding is attached (the export skips uncoded signals rather than
|
||||
// shipping an invented code).
|
||||
// better for both. No verified total-score LOINC exists for either, so both
|
||||
// carry the `loinc: null` text-only survey concept the physical signals
|
||||
// already use — an uncoded finding a clinician can read, never an invented
|
||||
// code. Without a `fhir` facet the FHIR adapter drops a signal entirely,
|
||||
// which is how a total the PDF and the share view show went missing from
|
||||
// every FHIR output.
|
||||
WHO5_SCORE: {
|
||||
key: "WHO5_SCORE",
|
||||
kind: "score",
|
||||
@@ -1295,6 +1298,13 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// No verified total-score LOINC — local text concept, `{score}` UCUM.
|
||||
loinc: null,
|
||||
display: "WHO-5 well-being index total score",
|
||||
unit: "{score}",
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
SCI_SCORE: {
|
||||
key: "SCI_SCORE",
|
||||
@@ -1312,6 +1322,13 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// No verified total-score LOINC — local text concept, `{score}` UCUM.
|
||||
loinc: null,
|
||||
display: "Sleep Condition Indicator total score",
|
||||
unit: "{score}",
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
|
||||
GRIP_STRENGTH: {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
doctorReportPrefsSchema,
|
||||
parseDoctorReportPrefs,
|
||||
resolveDoctorReportPrefs,
|
||||
} from "../doctor-report-prefs";
|
||||
|
||||
/**
|
||||
* v1.4.25 W6c — per-user Doctor-Report section toggles.
|
||||
*/
|
||||
describe("doctorReportPrefsSchema", () => {
|
||||
it("accepts an empty object (every key is optional)", () => {
|
||||
const out = doctorReportPrefsSchema.parse({});
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts the full shape and round-trips it", () => {
|
||||
const input = {
|
||||
bp: true,
|
||||
weight: false,
|
||||
pulse: true,
|
||||
bmi: true,
|
||||
mood: false,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
};
|
||||
const out = doctorReportPrefsSchema.parse(input);
|
||||
expect(out).toEqual(input);
|
||||
});
|
||||
|
||||
it("accepts a partial update (only the toggled keys)", () => {
|
||||
const out = doctorReportPrefsSchema.parse({ mood: true });
|
||||
expect(out).toEqual({ mood: true });
|
||||
});
|
||||
|
||||
it("rejects a non-boolean value", () => {
|
||||
const result = doctorReportPrefsSchema.safeParse({ bp: "yes" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDoctorReportPrefs", () => {
|
||||
it("returns defaults for null input", () => {
|
||||
expect(parseDoctorReportPrefs(null)).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
|
||||
});
|
||||
|
||||
it("returns defaults for undefined input", () => {
|
||||
expect(parseDoctorReportPrefs(undefined)).toEqual(
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults mood to OFF (privacy directive per the maintainer)", () => {
|
||||
const out = parseDoctorReportPrefs(null);
|
||||
expect(out.mood).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults glucose to ON (per-report control, parity with pre-toggle behaviour)", () => {
|
||||
const out = parseDoctorReportPrefs(null);
|
||||
expect(out.glucose).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an explicit glucose=false partial update", () => {
|
||||
const out = parseDoctorReportPrefs({ glucose: false });
|
||||
expect(out.glucose).toBe(false);
|
||||
// Other sections keep their defaults.
|
||||
expect(out.labs).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults labs to ON (recorded to share with a clinician)", () => {
|
||||
const out = parseDoctorReportPrefs(null);
|
||||
expect(out.labs).toBe(true);
|
||||
});
|
||||
|
||||
it("fills missing keys from defaults", () => {
|
||||
const out = parseDoctorReportPrefs({ bp: false });
|
||||
expect(out).toEqual({
|
||||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
bp: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns defaults for malformed input (forward-compat fallback)", () => {
|
||||
expect(parseDoctorReportPrefs({ bp: "nope" })).toEqual(
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
);
|
||||
expect(parseDoctorReportPrefs("not an object")).toEqual(
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDoctorReportPrefs", () => {
|
||||
it("layers a partial update over current persisted values", () => {
|
||||
const current = {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
allergies: false,
|
||||
familyHistory: false,
|
||||
};
|
||||
const out = resolveDoctorReportPrefs(current, { mood: false });
|
||||
// Keys the persisted row predates (this change added the measurement-group,
|
||||
// medication-list and screener flags) fill from the defaults, so the
|
||||
// resolved shape is always complete for the aggregator.
|
||||
expect(out).toEqual({
|
||||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
...current,
|
||||
mood: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("layers a partial update over defaults when current is null", () => {
|
||||
const out = resolveDoctorReportPrefs(null, { mood: true });
|
||||
expect(out).toEqual({ ...DEFAULT_DOCTOR_REPORT_PREFS, mood: true });
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,107 @@
|
||||
/**
|
||||
* The export request contract.
|
||||
*
|
||||
* `.strict()` plus a required `selection` is the whole point: a body that says
|
||||
* nothing about scope is refused rather than resolved to a server default, and
|
||||
* the retired grouped `sections` shape is refused by name rather than folded
|
||||
* into one. Silently accepting a payload that does not express a scope is the
|
||||
* defect this release removes.
|
||||
*
|
||||
* Mutation checks:
|
||||
* - make `selection` `.optional()` → "refuses a body that states no scope"
|
||||
* goes red.
|
||||
* - drop `.strict()` → "refuses the retired sections shape by name" and
|
||||
* "rejects a userId smuggled into the body" go red.
|
||||
* - re-add `includeAiSummary` to the schema → "refuses the retired AI summary
|
||||
* flag" goes red.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
exportSelectionSchema,
|
||||
toDoctorReportPrefs,
|
||||
} from "../health-record-export";
|
||||
|
||||
import { exportSelectionSchema } from "../health-record-export";
|
||||
|
||||
const SELECTION = { v: 2, leaves: ["WEIGHT"] };
|
||||
|
||||
describe("exportSelectionSchema", () => {
|
||||
it("accepts a minimal valid payload", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({ format: "pdf" });
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts each supported format", () => {
|
||||
for (const format of ["pdf", "fhir", "package"]) {
|
||||
expect(exportSelectionSchema.safeParse({ format }).success).toBe(true);
|
||||
expect(
|
||||
exportSelectionSchema.safeParse({ format, selection: SELECTION })
|
||||
.success,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown format", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({ format: "xml" });
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "xml",
|
||||
selection: SELECTION,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing format", () => {
|
||||
expect(exportSelectionSchema.safeParse({}).success).toBe(false);
|
||||
expect(
|
||||
exportSelectionSchema.safeParse({ selection: SELECTION }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a body that states no scope", () => {
|
||||
// An omitted selection is not "everything" and not "the defaults" — it is
|
||||
// a caller who did not say, and the route has nothing honest to serve.
|
||||
const parsed = exportSelectionSchema.safeParse({ format: "pdf" });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicitly empty scope", () => {
|
||||
// Saying "nothing" is a legitimate statement, distinct from saying nothing.
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: { v: 2, leaves: [] },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a selection at the wrong version", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: { v: 1, leaves: ["WEIGHT"] },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the retired sections shape by name", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
sections: { vitals: { bp: true } },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success) {
|
||||
expect(JSON.stringify(parsed.error.issues)).toContain("sections");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses the retired AI summary flag", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
includeAiSummary: true,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown top-level keys (.strict)", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
unexpected: true,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
@@ -36,81 +110,47 @@ describe("exportSelectionSchema", () => {
|
||||
it("rejects a userId smuggled into the body", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
userId: "someone-else",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown key inside range (.strict)", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "fhir",
|
||||
range: { days: 30, evil: 1 },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces the range.days cap (1..365)", () => {
|
||||
expect(
|
||||
exportSelectionSchema.safeParse({ format: "pdf", range: { days: 0 } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
exportSelectionSchema.safeParse({ format: "pdf", range: { days: 366 } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
exportSelectionSchema.safeParse({ format: "pdf", range: { days: 365 } })
|
||||
.success,
|
||||
exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
range: { days: 365 },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
range: { days: 366 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns all issues, not just the first", () => {
|
||||
it("accepts an absolute custom window", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "xml",
|
||||
range: { days: 9999 },
|
||||
unknownKey: 1,
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
range: {
|
||||
startDate: "2026-01-01T00:00:00+00:00",
|
||||
endDate: "2026-02-01T00:00:00+00:00",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("caps the practice name", () => {
|
||||
const parsed = exportSelectionSchema.safeParse({
|
||||
format: "pdf",
|
||||
selection: SELECTION,
|
||||
practiceName: "x".repeat(121),
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success) {
|
||||
expect(parsed.error.issues.length).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDoctorReportPrefs", () => {
|
||||
it("defaults to every section on except mood when no selection given", () => {
|
||||
const prefs = toDoctorReportPrefs(undefined);
|
||||
expect(prefs.weight).toBe(true);
|
||||
expect(prefs.bp).toBe(true);
|
||||
expect(prefs.mood).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps mood off unless explicitly true", () => {
|
||||
expect(toDoctorReportPrefs({ mood: false }).mood).toBe(false);
|
||||
expect(toDoctorReportPrefs({}).mood).toBe(false);
|
||||
expect(toDoctorReportPrefs({ mood: true }).mood).toBe(true);
|
||||
});
|
||||
|
||||
it("folds grouped vitals down to the flat shape", () => {
|
||||
const prefs = toDoctorReportPrefs({
|
||||
vitals: { weight: false, bp: true },
|
||||
});
|
||||
expect(prefs.weight).toBe(false);
|
||||
expect(prefs.bp).toBe(true);
|
||||
});
|
||||
|
||||
it("maps medication compliance toggle", () => {
|
||||
expect(
|
||||
toDoctorReportPrefs({ medications: { compliance: false } }).compliance,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("threads the glucose section toggle (default ON, off when unchecked)", () => {
|
||||
// Default-on preserves the pre-toggle behaviour where glucose always
|
||||
// rendered; an explicit false withholds glucose from this report.
|
||||
expect(toDoctorReportPrefs(undefined).glucose).toBe(true);
|
||||
expect(toDoctorReportPrefs({}).glucose).toBe(true);
|
||||
expect(toDoctorReportPrefs({ glucose: true }).glucose).toBe(true);
|
||||
expect(toDoctorReportPrefs({ glucose: false }).glucose).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -32,20 +33,13 @@ const MODULES = {
|
||||
nutrients: true,
|
||||
} as const;
|
||||
|
||||
const SECTIONS = {
|
||||
bp: false,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: false,
|
||||
mood: false,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
allergies: false,
|
||||
familyHistory: false,
|
||||
} as const;
|
||||
/** The dense path is about pulse and glucose; nothing else is in scope here. */
|
||||
const SELECTION = selectionFromLeaves([
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"GLUCOSE_PANEL",
|
||||
]);
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -138,8 +132,7 @@ describe("doctor report — bounded dense measurement reads", () => {
|
||||
await seedDenseBatch(0, 4);
|
||||
const findManySpy = vi.spyOn(prisma.measurement, "findMany");
|
||||
|
||||
const initial = await collectDoctorReportData(user.id, RANGE, {
|
||||
sections: { ...SECTIONS },
|
||||
const initial = await collectDoctorReportData(user.id, RANGE, SELECTION, {
|
||||
moduleMap: { ...MODULES },
|
||||
});
|
||||
|
||||
@@ -168,8 +161,7 @@ describe("doctor report — bounded dense measurement reads", () => {
|
||||
expect(rawRows.map((row) => row.type)).toEqual(["WEIGHT", "WEIGHT"]);
|
||||
|
||||
await seedDenseBatch(10, 300);
|
||||
const grown = await collectDoctorReportData(user.id, RANGE, {
|
||||
sections: { ...SECTIONS },
|
||||
const grown = await collectDoctorReportData(user.id, RANGE, SELECTION, {
|
||||
moduleMap: { ...MODULES },
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
|
||||
import {
|
||||
EMPTY_REPORT_SELECTION,
|
||||
selectionFromLeaves,
|
||||
} from "@/lib/report-selection/selection";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
@@ -217,20 +222,22 @@ function pdfContainsText(bytes: Uint8Array, needle: string): boolean {
|
||||
}
|
||||
|
||||
describe("doctor-report — per-section toggles", () => {
|
||||
it("drops mood data entirely when sections.mood = false", async () => {
|
||||
it("drops mood data entirely when the mood leaf is not chosen", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-mood-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
sections: {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: true,
|
||||
mood: false, // ← the privacy default
|
||||
compliance: true,
|
||||
sleep: true,
|
||||
},
|
||||
});
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
selectionFromLeaves([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BODY_MASS_INDEX",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"SLEEP_DURATION",
|
||||
]),
|
||||
);
|
||||
|
||||
// The aggregator must NOT return mood data when the toggle is off.
|
||||
// The contract: the data never leaves the DB row, so the JSON
|
||||
@@ -249,31 +256,27 @@ describe("doctor-report — per-section toggles", () => {
|
||||
expect(pdfContainsText(pdfEn, "Mood")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes mood data when sections.mood = true (opt-in)", async () => {
|
||||
it("includes mood data when the mood leaf is chosen by name", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-mood-on");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
sections: { mood: true },
|
||||
});
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
selectionFromLeaves(["MOOD"]),
|
||||
);
|
||||
|
||||
expect(data.mood).not.toBeNull();
|
||||
expect(data.mood?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("strips BP / weight / pulse / compliance when their toggles are off", async () => {
|
||||
it("strips BP / weight / pulse / compliance when their leaves are not chosen", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-others-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
sections: {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: false,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
},
|
||||
});
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
selectionFromLeaves(["LAB_RESULTS"]),
|
||||
);
|
||||
|
||||
expect(data.stats.BLOOD_PRESSURE_SYS).toBeUndefined();
|
||||
expect(data.stats.BLOOD_PRESSURE_DIA).toBeUndefined();
|
||||
@@ -284,19 +287,29 @@ describe("doctor-report — per-section toggles", () => {
|
||||
expect(data.mood).toBeNull();
|
||||
});
|
||||
|
||||
it("applies documented defaults when sections is omitted (mood OFF, others ON)", async () => {
|
||||
it("serves nothing at all for an empty selection", async () => {
|
||||
// The case the release exists for. An account with data in every domain
|
||||
// and a caller that named no leaf gets an empty record — not the
|
||||
// "everything except mood and cycle" the old defaults produced.
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-defaults");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE);
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
EMPTY_REPORT_SELECTION,
|
||||
);
|
||||
|
||||
// Privacy default per the maintainer — mood is opt-in, not opt-out.
|
||||
expect(data.mood).toBeNull();
|
||||
// Every other section keeps its data.
|
||||
expect(data.stats.WEIGHT).toBeDefined();
|
||||
expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
|
||||
expect(data.stats.PULSE).toBeDefined();
|
||||
expect(data.bmi).not.toBeNull();
|
||||
expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
|
||||
expect(data.stats).toEqual({});
|
||||
expect(data.measurements).toEqual({});
|
||||
expect(data.bmi).toBeNull();
|
||||
expect(data.compliance).toEqual({});
|
||||
expect(data.medications).toEqual([]);
|
||||
expect(data.labResults).toBeNull();
|
||||
expect(data.allergies).toBeNull();
|
||||
expect(data.familyHistory).toBeNull();
|
||||
expect(data.patient.username).toBeNull();
|
||||
expect(data.patient.insurerName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +340,12 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
it("includes every module's data when all modules are enabled", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-modules-all-on");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
// Opt mood in so the present-case asserts the full surface.
|
||||
sections: { mood: true },
|
||||
moduleMap: { ...ALL_ON },
|
||||
});
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
selectionFromLeaves(ALL_LEAF_IDS),
|
||||
{ moduleMap: { ...ALL_ON } },
|
||||
);
|
||||
|
||||
expect(Object.keys(data.glucoseStats).length).toBeGreaterThan(0);
|
||||
expect(data.measurements.ACTIVITY_STEPS).toBeDefined();
|
||||
@@ -351,20 +365,24 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
it("excludes a disabled module's section/resources from the payload", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-modules-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
// The user opted mood in at the report level, but turned the modules off
|
||||
// — the module gate wins, so none of these surface.
|
||||
sections: { mood: true, sleep: true, labs: true },
|
||||
moduleMap: {
|
||||
...ALL_ON,
|
||||
glucose: false,
|
||||
sleep: false,
|
||||
workouts: false,
|
||||
recovery: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
// Every leaf chosen at the report level, but the modules are off — the
|
||||
// module gate wins, so none of these surface.
|
||||
selectionFromLeaves(ALL_LEAF_IDS),
|
||||
{
|
||||
moduleMap: {
|
||||
...ALL_ON,
|
||||
glucose: false,
|
||||
sleep: false,
|
||||
workouts: false,
|
||||
recovery: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// Glucose panel collapses entirely (stats + raw series).
|
||||
expect(data.glucoseStats).toEqual({});
|
||||
@@ -394,9 +412,12 @@ describe("doctor-report — module enable/disable gating", () => {
|
||||
it("gates recovery and workouts independently (recovery off, workouts on)", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-modules-recovery-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
moduleMap: { ...ALL_ON, recovery: false },
|
||||
});
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
selectionFromLeaves(ALL_LEAF_IDS),
|
||||
{ moduleMap: { ...ALL_ON, recovery: false } },
|
||||
);
|
||||
|
||||
const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type);
|
||||
// Recovery readiness scores gone…
|
||||
@@ -447,7 +468,11 @@ describe("doctor-report — MedicationAdministration export cap", () => {
|
||||
});
|
||||
await prisma.medicationIntakeEvent.createMany({ data: rows });
|
||||
|
||||
const data = await collectDoctorReportData(user.id, RANGE);
|
||||
const data = await collectDoctorReportData(
|
||||
user.id,
|
||||
RANGE,
|
||||
selectionFromLeaves(["MEDICATION_ADMINISTRATIONS"]),
|
||||
);
|
||||
|
||||
expect(data.medicationAdministrations).toBeDefined();
|
||||
expect(data.medicationAdministrations?.length).toBe(1000);
|
||||
@@ -466,7 +491,11 @@ describe("doctor-report — MedicationAdministration export cap", () => {
|
||||
it("leaves the set untouched (no truncation flag) below the cap", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-admin-no-cap");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE);
|
||||
const data = await collectDoctorReportData(
|
||||
userId,
|
||||
RANGE,
|
||||
selectionFromLeaves(["MEDICATION_ADMINISTRATIONS"]),
|
||||
);
|
||||
|
||||
expect(data.medicationAdministrations?.length).toBe(1);
|
||||
expect(data.medicationAdministrationsTruncation ?? null).toBeNull();
|
||||
|
||||
@@ -17,6 +17,7 @@ process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -106,7 +107,15 @@ describe("medicationAdministrations aggregation (v1.9.0)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const data = await collectDoctorReportData(user.id, RANGE);
|
||||
const data = await collectDoctorReportData(
|
||||
user.id,
|
||||
RANGE,
|
||||
selectionFromLeaves([
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_ADMINISTRATIONS",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
]),
|
||||
);
|
||||
|
||||
// Drug codes carried onto the medication concept source.
|
||||
expect(data.medications[0].atcCode).toBe("A10BX10");
|
||||
|
||||
Reference in new issue
Block a user