mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #643 from MBombeck/release/v1.32.39
v1.32.39 — the doctor report carries only what was chosen
This commit is contained in:
159 files changed
+13080
-6444
No files matched your search
@@ -2,6 +2,72 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.39] — 2026-07-26
|
||||
|
||||
The doctor report now carries what you chose, and nothing else.
|
||||
|
||||
**Ten of the twenty switches did nothing.** The panel offered controls for
|
||||
oxygen saturation, body fat, body composition, resting heart rate, heart-rate
|
||||
variability, VO2 max, steps and distance. Switching them off changed nothing:
|
||||
the value was folded away before it reached the report. Two more claimed less
|
||||
than they did, and the medication switch left the dose-by-dose record in place
|
||||
while appearing to remove the drug list.
|
||||
|
||||
**Allergies and family history went out regardless.** They were read straight
|
||||
from the database in the export route, so a report could exclude them and the
|
||||
structured file in the same download would still contain them.
|
||||
|
||||
**The mental-health questionnaires went out without being asked for.** The
|
||||
depression, anxiety, wellbeing and sleep scores reached the structured export,
|
||||
the automation interface and a shared link, gated only by a module that had
|
||||
been switched on by default some releases ago. They now have their own entry
|
||||
and are off unless you pick them.
|
||||
|
||||
**The cycle export shipped ten other things.** The card that promised to export
|
||||
only cycle data sent an empty request, and an empty request fell back to the
|
||||
defaults, so it also carried weight, blood pressure, pulse, oxygen saturation,
|
||||
body fat, the medication list, adherence, lab results, allergies and family
|
||||
history. The comment above that code said every other section was off.
|
||||
|
||||
**What replaces all of it.** One picker, on both surfaces that choose a scope.
|
||||
Twelve groups, each releasable or excludable as a whole and openable down to
|
||||
the individual value. All 77 measurement types and 13 record sections are in
|
||||
it, each in exactly one group. Sensitive material sits below a divider with no
|
||||
group control, so no single click can turn on more than one of those at a time.
|
||||
|
||||
Nothing is preselected. A new report starts empty and one button fills in what
|
||||
a general visit usually needs, which you can then change. Generating is not
|
||||
possible while nothing is chosen.
|
||||
|
||||
A metric added to the app in future cannot appear in a scope you saved
|
||||
earlier. That is not a promise, it is a compile error: a new measurement type
|
||||
without a group breaks the build, and every path that produces a report has to
|
||||
name where its scope came from.
|
||||
|
||||
**Share links.** A link can now be fetched as a PDF or as a structured file
|
||||
behind the same passphrase that already protects the view, which is what a
|
||||
practice actually does with one. The old promise of an interface that never
|
||||
existed is gone. Links created before this release carry no explicit choice
|
||||
and are therefore closed; each one can be reissued in a click with its label,
|
||||
period and expiry carried over.
|
||||
|
||||
**The written summary has left the report.** It could not be checked against
|
||||
what you chose, and prose whose scope cannot be shown has no place on a
|
||||
document whose value is exactly that.
|
||||
|
||||
**Structured export corrections.** Someone who records their gender as
|
||||
something other than female or male was exported as female. FHIR has a code
|
||||
for it and it is now used, and an unrecognised value is left out rather than
|
||||
guessed. The capability document described a resource twice and omitted
|
||||
another that was actually served; both corrected.
|
||||
|
||||
**Also in this release.** Roughly one page load in three showed raw text keys
|
||||
for a moment instead of words, because the language file was racing the
|
||||
application bundle and losing. It is now loaded before the first render.
|
||||
|
||||
**For operators.** Migration `0275` stores the new scope and removes the old
|
||||
preferences. Existing share links are closed by it and need reissuing.
|
||||
|
||||
## [1.32.38] — 2026-07-25
|
||||
|
||||
Blood-glucose readings from anything other than the manual entry form were
|
||||
|
||||
+288
-182
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.32.38
|
||||
version: 1.32.39
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -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,69 +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
|
||||
energy:
|
||||
type: boolean
|
||||
sleep:
|
||||
type: boolean
|
||||
glucose:
|
||||
type: boolean
|
||||
medications:
|
||||
type: object
|
||||
properties:
|
||||
list:
|
||||
type: boolean
|
||||
compliance:
|
||||
type: boolean
|
||||
glp1:
|
||||
type: boolean
|
||||
sideEffects:
|
||||
type: boolean
|
||||
mood:
|
||||
type: boolean
|
||||
bmi:
|
||||
type: boolean
|
||||
cycle:
|
||||
type: boolean
|
||||
labs:
|
||||
type: boolean
|
||||
allergies:
|
||||
type: boolean
|
||||
familyHistory:
|
||||
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:
|
||||
@@ -981,12 +937,11 @@ paths:
|
||||
maxLength: 120
|
||||
includeCharts:
|
||||
type: boolean
|
||||
includeAiSummary:
|
||||
type: boolean
|
||||
germanAtc:
|
||||
type: boolean
|
||||
required:
|
||||
- format
|
||||
- selection
|
||||
additionalProperties: false
|
||||
responses:
|
||||
"200":
|
||||
@@ -1014,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:
|
||||
@@ -1043,81 +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
|
||||
energy:
|
||||
type: boolean
|
||||
sleep:
|
||||
type: boolean
|
||||
glucose:
|
||||
type: boolean
|
||||
medications:
|
||||
type: object
|
||||
properties:
|
||||
list:
|
||||
type: boolean
|
||||
compliance:
|
||||
type: boolean
|
||||
glp1:
|
||||
type: boolean
|
||||
sideEffects:
|
||||
type: boolean
|
||||
mood:
|
||||
type: boolean
|
||||
bmi:
|
||||
type: boolean
|
||||
cycle:
|
||||
type: boolean
|
||||
labs:
|
||||
type: boolean
|
||||
allergies:
|
||||
type: boolean
|
||||
familyHistory:
|
||||
type: boolean
|
||||
resourceTypes:
|
||||
maxItems: 8
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- Patient
|
||||
- Observation
|
||||
- MedicationStatement
|
||||
- MedicationAdministration
|
||||
allowFhirApi:
|
||||
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
|
||||
@@ -1292,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).
|
||||
@@ -1413,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
|
||||
@@ -15650,9 +15737,10 @@ components:
|
||||
restBaseUrl:
|
||||
type: string
|
||||
description: Base path of the read-only FHIR R4 REST face.
|
||||
readScope:
|
||||
type: string
|
||||
description: Bearer scope a narrow token needs to read the FHIR face.
|
||||
scopeMintable:
|
||||
type: boolean
|
||||
description: "Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the
|
||||
scope, so the face is reachable by the owner's cookie session or a wildcard device token only."
|
||||
resourceTypes:
|
||||
type: array
|
||||
items:
|
||||
@@ -15673,7 +15761,7 @@ components:
|
||||
- snomedRoute
|
||||
- germanAtcDefaultLocales
|
||||
- restBaseUrl
|
||||
- readScope
|
||||
- scopeMintable
|
||||
- resourceTypes
|
||||
- operations
|
||||
- searchParams
|
||||
@@ -15690,23 +15778,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
|
||||
@@ -15735,16 +15835,18 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
resourceTypes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
protected:
|
||||
type: boolean
|
||||
documentCount:
|
||||
type: number
|
||||
needsReselection:
|
||||
type: boolean
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
resourceTypes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
expiresAt:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -15779,10 +15881,11 @@ components:
|
||||
- label
|
||||
- rangeStart
|
||||
- rangeEnd
|
||||
- resourceTypes
|
||||
- allowFhirApi
|
||||
- protected
|
||||
- documentCount
|
||||
- needsReselection
|
||||
- allowFhirApi
|
||||
- resourceTypes
|
||||
- expiresAt
|
||||
- createdAt
|
||||
- revokedAt
|
||||
@@ -15827,16 +15930,18 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
resourceTypes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
protected:
|
||||
type: boolean
|
||||
documentCount:
|
||||
type: number
|
||||
needsReselection:
|
||||
type: boolean
|
||||
allowFhirApi:
|
||||
type: boolean
|
||||
resourceTypes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
expiresAt:
|
||||
type: string
|
||||
createdAt:
|
||||
@@ -15858,10 +15963,11 @@ components:
|
||||
- label
|
||||
- rangeStart
|
||||
- rangeEnd
|
||||
- resourceTypes
|
||||
- allowFhirApi
|
||||
- protected
|
||||
- documentCount
|
||||
- needsReselection
|
||||
- allowFhirApi
|
||||
- resourceTypes
|
||||
- expiresAt
|
||||
- createdAt
|
||||
- revokedAt
|
||||
|
||||
+176
-27
@@ -1,3 +1,5 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
@@ -12,9 +14,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* and generic-JSON cards.
|
||||
* 3. The full health-record export panel lives on its own top-level
|
||||
* `/settings/gesundheitsakte` section (v1.18.0 S5) — including the
|
||||
* "included data" checklist, a disclosure collapsed by default that
|
||||
* expands on demand.
|
||||
* 4. Clicking the Measurements CSV download button fires a real
|
||||
* "included data" disclosure, which a first run opens and which stays
|
||||
* operable in both directions.
|
||||
* 4. The scope picker mounts twice on that page (the export panel and the
|
||||
* share-link create form) and each mount is separately addressable.
|
||||
* 5. Clicking the Measurements CSV download button fires a real
|
||||
* browser download — proving the `/api/export/measurements`
|
||||
* endpoint is reachable from the browser end-to-end.
|
||||
*
|
||||
@@ -22,6 +26,30 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* download is enough to lock in the wiring without doubling the e2e
|
||||
* runtime.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Open Settings → Gesundheitsakte and wait until the page is actually live.
|
||||
*
|
||||
* Everything on that page is server-rendered, so the rows are visible long
|
||||
* before the client boundary that owns them has hydrated — a click or a
|
||||
* `boundingBox()` taken on `domcontentloaded` alone hits inert or replaced
|
||||
* markup often enough to matter. `GesundheitsakteSection` is one client
|
||||
* boundary carrying both the export panel and the sharing card, and the
|
||||
* sharing card's query only runs once that boundary is live, so its request is
|
||||
* the signal that the whole surface is wired.
|
||||
*/
|
||||
async function openGesundheitsakte(page: Page): Promise<void> {
|
||||
const boundaryHydrated = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/share-links",
|
||||
);
|
||||
await page.goto("/settings/gesundheitsakte", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await boundaryHydrated;
|
||||
}
|
||||
|
||||
test.describe("Settings → Export & Import", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
@@ -45,14 +73,136 @@ test.describe("Settings → Export & Import", () => {
|
||||
}) => {
|
||||
// v1.18.0 (S5) — the health-record export is the hero of the
|
||||
// dedicated, module-gated Gesundheitsakte section.
|
||||
await page.goto("/settings/gesundheitsakte", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await openGesundheitsakte(page);
|
||||
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("gives each scope-picker mount its own test ids", async ({ page }) => {
|
||||
// The Gesundheitsakte page carries two mounts of the same picker: the
|
||||
// export panel at the top and the share-link create form under `#sharing`.
|
||||
// Every id inside the picker ends with the surface that mounts it, so an
|
||||
// assertion binds to one mount instead of resolving to both.
|
||||
await openGesundheitsakte(page);
|
||||
await expect(page.getByTestId("report-scope-picker-export")).toHaveCount(1);
|
||||
await expect(page.getByTestId("report-scope-picker-share")).toHaveCount(1);
|
||||
for (const id of [
|
||||
"report-group-row-vitals",
|
||||
"report-group-check-vitals",
|
||||
"report-sensitive-tier",
|
||||
"report-scope-summary",
|
||||
]) {
|
||||
await expect(page.getByTestId(`${id}-export`)).toHaveCount(1);
|
||||
await expect(page.getByTestId(`${id}-share`)).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The three panel states are the release's whole point, and on the first
|
||||
// one nothing is selected at all. The picker still opens without a click,
|
||||
// because with an empty scope the standard-report button and the named
|
||||
// groups are exactly what the person needs in front of them. Asserted
|
||||
// against the export mount; the share form runs its own picker.
|
||||
await openGesundheitsakte(page);
|
||||
await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-vitals-export"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-sensitive-export"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("report-sensitive-tier-export"),
|
||||
).toBeVisible();
|
||||
// The fenced tier has no group control — there is no single click that
|
||||
// turns on more than one sensitive leaf.
|
||||
await expect(
|
||||
page.getByTestId("report-group-check-sensitive-export"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("report-scope-summary-export")).toBeVisible();
|
||||
});
|
||||
|
||||
test("first run selects nothing and refuses to generate until it does", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The rule the whole surface exists for: nothing reaches the report that
|
||||
// was not ticked. So a first run starts empty, Generate refuses the press
|
||||
// and says why, and the one-click action is what makes that cost nothing —
|
||||
// it fills the standard set and the control comes alive.
|
||||
await openGesundheitsakte(page);
|
||||
const generate = page.getByTestId("health-record-generate");
|
||||
await expect(generate).toBeVisible({ timeout: 10_000 });
|
||||
await expect(generate).toBeDisabled();
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-vitals-export"),
|
||||
).toContainText("0/7");
|
||||
await expect(page.getByTestId("report-scope-summary-export")).toContainText(
|
||||
"Nothing selected yet",
|
||||
);
|
||||
|
||||
await page.getByTestId("report-apply-standard-export").click();
|
||||
|
||||
await expect(generate).toBeEnabled();
|
||||
// Exactly the shipped set: the three classic vitals, weight and BMI, and
|
||||
// nothing at all from the groups the template leaves alone.
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-vitals-export"),
|
||||
).toContainText("3/7");
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-body-export"),
|
||||
).toContainText("2/12");
|
||||
await expect(
|
||||
page.getByTestId("report-group-row-cardio-export"),
|
||||
).toContainText("0/14");
|
||||
// The fenced tier is not part of any one-click set.
|
||||
await expect(
|
||||
page.getByTestId("report-scope-summary-export"),
|
||||
).not.toContainText("incl.");
|
||||
});
|
||||
|
||||
test("a share link cannot be minted on an empty scope either", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGesundheitsakte(page);
|
||||
const create = page.getByTestId("share-create-submit");
|
||||
await expect(create).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator("#share-label").fill("Cardiology");
|
||||
// A label alone is not a scope: the link would serve a page with nothing
|
||||
// on it, so the control stays shut until something is chosen.
|
||||
await expect(create).toBeDisabled();
|
||||
await expect(page.getByTestId("report-scope-summary-share")).toContainText(
|
||||
"Nothing selected yet",
|
||||
);
|
||||
await page.getByTestId("report-apply-standard-share").click();
|
||||
await expect(create).toBeEnabled();
|
||||
});
|
||||
|
||||
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openGesundheitsakte(page);
|
||||
const row = page.getByTestId("report-group-row-vitals-export");
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
const box = await row.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
test("opens a group's leaf grid from its chevron", async ({ page }) => {
|
||||
await openGesundheitsakte(page);
|
||||
const row = page.getByTestId("report-group-row-vitals-export");
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
// Collapsed: the leaves are behind the disclosure, which is what makes a
|
||||
// repeat run two interactions.
|
||||
await expect(page.getByTestId("report-leaf-PULSE-export")).toHaveCount(0);
|
||||
await row.getByRole("button", { expanded: false }).click();
|
||||
await expect(page.getByTestId("report-leaf-PULSE-export")).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders the import surfaces with stable testids", async ({ page }) => {
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
|
||||
@@ -89,11 +239,18 @@ test.describe("Settings → Export & Import", () => {
|
||||
expect(Array.isArray(parsed.moodEntries)).toBe(true);
|
||||
});
|
||||
|
||||
test("included-data checklist is collapsed by default and expands on demand", async ({
|
||||
test("included-data disclosure opens on a first run and still collapses", async ({
|
||||
page,
|
||||
}) => {
|
||||
// v1.18.0 (S5) — the panel (and its disclosure) live on the
|
||||
// dedicated Gesundheitsakte section now.
|
||||
// The disclosure that fronts the scope picker on
|
||||
// `/settings/gesundheitsakte`. An account with no saved selection is a
|
||||
// first run, and a first run opens it: that is the only place the standard
|
||||
// template is applied, and a template hidden behind a closed disclosure
|
||||
// would be opt-out wearing a hat.
|
||||
//
|
||||
// Opening by default must not turn the disclosure into decoration, so the
|
||||
// operable part is asserted in both directions — it closes, taking the
|
||||
// panel with it, and it reopens.
|
||||
await page.route("**/api/share-links", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
@@ -105,29 +262,21 @@ test.describe("Settings → Export & Import", () => {
|
||||
body: JSON.stringify({ data: [], error: null, meta: null }),
|
||||
});
|
||||
});
|
||||
const sharingDataReady = page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 &&
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/share-links",
|
||||
);
|
||||
await page.goto("/settings/gesundheitsakte", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await sharingDataReady;
|
||||
await openGesundheitsakte(page);
|
||||
const toggle = page.getByTestId("health-record-included-data-toggle");
|
||||
const panel = page.getByTestId("health-record-included-data-panel");
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
// Collapsed on first render — the checklist panel is absent.
|
||||
// Open on first render, with the checklist panel behind it.
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(panel).toBeVisible();
|
||||
// Collapses on demand, and the panel goes with it.
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(
|
||||
page.getByTestId("health-record-included-data-panel"),
|
||||
).toHaveCount(0);
|
||||
// Expands on demand.
|
||||
await expect(panel).toHaveCount(0);
|
||||
// And back — the disclosure stays a disclosure.
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(
|
||||
page.getByTestId("health-record-included-data-panel"),
|
||||
).toBeVisible();
|
||||
await expect(panel).toBeVisible();
|
||||
});
|
||||
|
||||
test("Measurements CSV download fires a real download event", async ({
|
||||
|
||||
@@ -51,9 +51,10 @@ interface CreatedShare {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the owner create flow: open the picker, isolate the share-fixture trio
|
||||
* by search, select all three, create the link, and read the one-time token +
|
||||
* passphrase off the reveal card. Also asserts the QR promotion.
|
||||
* Drive the owner create flow: name the link, choose a record scope, open the
|
||||
* picker, isolate the share-fixture trio by search, select all three, create
|
||||
* the link, and read the one-time token + passphrase off the reveal card. Also
|
||||
* asserts the QR promotion.
|
||||
*/
|
||||
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
|
||||
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
|
||||
@@ -67,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
|
||||
await page.goto("/settings/sharing");
|
||||
await sharingDataReady;
|
||||
await page.locator("#share-label").fill(label);
|
||||
// A record link starts with an empty scope and cannot be minted on one, so
|
||||
// the scope is chosen here the way an owner chooses it: one click on the
|
||||
// named standard set.
|
||||
await page.getByTestId("report-apply-standard-share").click();
|
||||
|
||||
// Open the document picker and isolate the seeded trio.
|
||||
await page.getByTestId("share-attach-open").click();
|
||||
|
||||
+70
-52
@@ -53,11 +53,15 @@
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"create": { "invalid": "Prüfe die Messdaten und versuche es erneut." },
|
||||
"create": {
|
||||
"invalid": "Prüfe die Messdaten und versuche es erneut."
|
||||
},
|
||||
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits eine Messung vorhanden."
|
||||
},
|
||||
"mood": {
|
||||
"list": { "invalid": "Die Filter der Stimmungsliste sind ungültig." },
|
||||
"list": {
|
||||
"invalid": "Die Filter der Stimmungsliste sind ungültig."
|
||||
},
|
||||
"create": {
|
||||
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
|
||||
},
|
||||
@@ -71,8 +75,12 @@
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"create": { "invalid": "Prüfe den Laborwert und versuche es erneut." },
|
||||
"result": { "notFound": "Dieser Laborwert ist nicht mehr verfügbar." },
|
||||
"create": {
|
||||
"invalid": "Prüfe den Laborwert und versuche es erneut."
|
||||
},
|
||||
"result": {
|
||||
"notFound": "Dieser Laborwert ist nicht mehr verfügbar."
|
||||
},
|
||||
"biomarker": {
|
||||
"notFound": "Dieser Biomarker ist nicht mehr verfügbar.",
|
||||
"duplicate": "Ein Biomarker mit diesem Namen ist bereits vorhanden.",
|
||||
@@ -102,7 +110,9 @@
|
||||
"complete": {
|
||||
"invalid": "Prüfe die Profildaten und versuche es erneut."
|
||||
},
|
||||
"user": { "notFound": "Dein Konto wurde nicht gefunden." },
|
||||
"user": {
|
||||
"notFound": "Dein Konto wurde nicht gefunden."
|
||||
},
|
||||
"step": {
|
||||
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es erneut.",
|
||||
"invalid": "Dieser Onboarding-Schritt konnte nicht gespeichert werden.",
|
||||
@@ -160,16 +170,6 @@
|
||||
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
|
||||
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
|
||||
"footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}",
|
||||
"typeWeight": "Körpergewicht",
|
||||
"typeBpSys": "Systolischer Blutdruck",
|
||||
"typeBpDia": "Diastolischer Blutdruck",
|
||||
"typePulse": "Ruhepuls",
|
||||
"typeBodyFat": "Körperfettanteil",
|
||||
"typeSleep": "Schlafdauer",
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
@@ -205,8 +205,6 @@
|
||||
"summaryTrend": "{label}: zuletzt {latest} {arrow}",
|
||||
"summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).",
|
||||
"chartsTitle": "Trend-Diagramme",
|
||||
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
|
||||
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
|
||||
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.",
|
||||
"wellnessTitle": "Wellness-Zusammenfassung",
|
||||
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
|
||||
@@ -4447,10 +4445,6 @@
|
||||
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
|
||||
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
|
||||
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
"description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
@@ -5313,34 +5307,17 @@
|
||||
"practiceName": "Praxis (optional)",
|
||||
"practiceNamePlaceholder": "z. B. Dr. med. Mustermann",
|
||||
"includedData": "Enthaltene Daten",
|
||||
"groupVitals": "Vitalwerte",
|
||||
"groupCardio": "Herz & Fitness",
|
||||
"groupActivity": "Aktivität & Schlaf",
|
||||
"weight": "Gewicht",
|
||||
"bp": "Blutdruck",
|
||||
"pulse": "Puls",
|
||||
"oxygenSaturation": "Sauerstoffsättigung",
|
||||
"bodyFat": "Körperfett",
|
||||
"bodyComposition": "Körperzusammensetzung",
|
||||
"restingHeartRate": "Ruhepuls",
|
||||
"hrv": "Herzfrequenzvariabilität",
|
||||
"vo2max": "VO₂max",
|
||||
"steps": "Schritte",
|
||||
"distance": "Distanz",
|
||||
"sleep": "Schlaf",
|
||||
"glucose": "Blutzucker",
|
||||
"medications": "Medikamente & Therapietreue",
|
||||
"mood": "Stimmung (standardmäßig aus — sensible Daten)",
|
||||
"bmi": "BMI",
|
||||
"includeCharts": "Trend-Diagramme einbetten (nur PDF)",
|
||||
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
|
||||
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
|
||||
"generate": "Erstellen",
|
||||
"error": "Export fehlgeschlagen ({code}).",
|
||||
"labs": "Laborwerte",
|
||||
"allergies": "Allergien & Unverträglichkeiten",
|
||||
"familyHistory": "Familienanamnese",
|
||||
"documentsLink": "Dokumente zum Zeitraum"
|
||||
"documentsLink": "Dokumente zum Zeitraum",
|
||||
"range30": "Letzte 30 Tage",
|
||||
"rangeCustom": "Eigener Zeitraum",
|
||||
"rangeCustomStart": "Von",
|
||||
"rangeCustomEnd": "Bis",
|
||||
"errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
|
||||
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet."
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
|
||||
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
|
||||
@@ -5354,9 +5331,6 @@
|
||||
"expiry": "Läuft ab in (Tagen)",
|
||||
"expiryHint": "Höchstens {max} Tage.",
|
||||
"expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.",
|
||||
"fhirApi": "Eingegrenzte FHIR-API erlauben",
|
||||
"fhirApiHint": "Lasse den Link zusätzlich zur Seitenansicht einen schreibgeschützten FHIR-Endpunkt bereitstellen.",
|
||||
"resourceTypes": "FHIR-Ressourcentypen",
|
||||
"create": "Link erstellen",
|
||||
"tokenCreated": "Freigabe-Link erstellt",
|
||||
"copied": "In die Zwischenablage kopiert.",
|
||||
@@ -5394,7 +5368,12 @@
|
||||
"pickerEmpty": "Keine Dokumente gefunden.",
|
||||
"pickerDone": "Fertig",
|
||||
"pickerSelectedCount": "{count} von {max} ausgewählt",
|
||||
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht."
|
||||
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht.",
|
||||
"scopeTitle": "Was dieser Link enthält",
|
||||
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
|
||||
"statusNeedsReselect": "Inhalt neu wählen",
|
||||
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.",
|
||||
"reselectAction": "Ersatz anlegen"
|
||||
},
|
||||
"genderOther": "Divers",
|
||||
"cycleTracking": {
|
||||
@@ -6930,7 +6909,6 @@
|
||||
"period": "Berichtszeitraum: {start} – {end}",
|
||||
"expires": "Dieser Link läuft am {date} ab.",
|
||||
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
|
||||
"vitals": "Vitalwerte",
|
||||
"labs": "Laborwerte",
|
||||
"medications": "Medikamente & Therapietreue",
|
||||
"bmi": "Body-Mass-Index (BMI)",
|
||||
@@ -6970,7 +6948,10 @@
|
||||
"download": "Herunterladen",
|
||||
"attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).",
|
||||
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
|
||||
}
|
||||
},
|
||||
"downloadPdf": "Als PDF herunterladen",
|
||||
"downloadFhir": "Als FHIR herunterladen",
|
||||
"bmiSection": "Body-Mass-Index"
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -8877,5 +8858,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
"groupIdentity": "Stammdaten",
|
||||
"groupVitals": "Vitalwerte",
|
||||
"groupCardio": "Herz & Kreislauf",
|
||||
"groupBody": "Körpermaße",
|
||||
"groupSleepRecovery": "Schlaf & Erholung",
|
||||
"groupActivity": "Aktivität",
|
||||
"groupMobility": "Mobilität & Gang",
|
||||
"groupEnvironment": "Umgebung",
|
||||
"groupGlucose": "Blutzucker",
|
||||
"groupLabs": "Laborwerte",
|
||||
"groupMedications": "Medikamente",
|
||||
"groupHistory": "Vorerkrankungen & Allergien",
|
||||
"groupSensitive": "Sensible Daten",
|
||||
"leafPatientIdentity": "Name, Geburtsdatum, Geschlecht, Größe",
|
||||
"leafInsurance": "Krankenkasse und Versichertennummer",
|
||||
"leafGlucosePanel": "Blutzucker-Panel (Zeit im Zielbereich, GMI, Variabilität)",
|
||||
"leafLabResults": "Laborwerte",
|
||||
"leafMedicationList": "Medikamentenliste",
|
||||
"leafMedicationAdministrations": "Jede erfasste Einnahme",
|
||||
"leafMedicationCompliance": "Einnahmetreue",
|
||||
"leafGlp1Therapy": "GLP-1-Therapie (Dosisverlauf, Nebenwirkungen)",
|
||||
"leafAllergies": "Allergien und Unverträglichkeiten",
|
||||
"leafIllnessEpisodes": "Krankheitsepisoden",
|
||||
"leafFamilyHistory": "Familienanamnese",
|
||||
"leafMood": "Stimmung",
|
||||
"leafCycle": "Menstruationszyklus",
|
||||
"templateStandard": "Standard-Arztbericht",
|
||||
"applyStandard": "{template} übernehmen",
|
||||
"standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.",
|
||||
"groupCount": "{on}/{total}",
|
||||
"groupDisclosure": "Einträge in {group} anzeigen",
|
||||
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
|
||||
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
|
||||
"scopeCount": "{count} Einträge aus {groups} Bereichen",
|
||||
"scopeSensitive": "inkl. {names}"
|
||||
}
|
||||
}
|
||||
+85
-57
@@ -53,13 +53,21 @@
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"create": { "invalid": "Check the measurement details and try again." },
|
||||
"create": {
|
||||
"invalid": "Check the measurement details and try again."
|
||||
},
|
||||
"duplicate_timestamp": "A measurement already exists for this time."
|
||||
},
|
||||
"mood": {
|
||||
"list": { "invalid": "The mood list filters are invalid." },
|
||||
"create": { "invalid": "Check the mood entry and try again." },
|
||||
"update": { "invalid": "Check the mood entry and try again." },
|
||||
"list": {
|
||||
"invalid": "The mood list filters are invalid."
|
||||
},
|
||||
"create": {
|
||||
"invalid": "Check the mood entry and try again."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Check the mood entry and try again."
|
||||
},
|
||||
"duplicate_timestamp": "A mood entry already exists for this time.",
|
||||
"not_found": "This mood entry is no longer available.",
|
||||
"ratedFactor": {
|
||||
@@ -67,12 +75,18 @@
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"create": { "invalid": "Check the lab result and try again." },
|
||||
"result": { "notFound": "This lab result is no longer available." },
|
||||
"create": {
|
||||
"invalid": "Check the lab result and try again."
|
||||
},
|
||||
"result": {
|
||||
"notFound": "This lab result is no longer available."
|
||||
},
|
||||
"biomarker": {
|
||||
"notFound": "This biomarker is no longer available.",
|
||||
"duplicate": "A biomarker with this name already exists.",
|
||||
"create": { "invalid": "Check the biomarker details and try again." },
|
||||
"create": {
|
||||
"invalid": "Check the biomarker details and try again."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Check the biomarker details and try again.",
|
||||
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
|
||||
@@ -90,9 +104,15 @@
|
||||
"disclaimer": {
|
||||
"invalid": "Please confirm the medical disclaimer again."
|
||||
},
|
||||
"tour": { "invalid": "The tour progress could not be saved." },
|
||||
"complete": { "invalid": "Check the profile details and try again." },
|
||||
"user": { "notFound": "Your account could not be found." },
|
||||
"tour": {
|
||||
"invalid": "The tour progress could not be saved."
|
||||
},
|
||||
"complete": {
|
||||
"invalid": "Check the profile details and try again."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Your account could not be found."
|
||||
},
|
||||
"step": {
|
||||
"rateLimited": "Too many attempts. Please wait and try again.",
|
||||
"invalid": "This onboarding step could not be saved.",
|
||||
@@ -150,16 +170,6 @@
|
||||
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
|
||||
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
|
||||
"footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}",
|
||||
"typeWeight": "Body weight",
|
||||
"typeBpSys": "Systolic blood pressure",
|
||||
"typeBpDia": "Diastolic blood pressure",
|
||||
"typePulse": "Resting pulse",
|
||||
"typeBodyFat": "Body fat",
|
||||
"typeSleep": "Sleep duration",
|
||||
"typeSteps": "Activity (steps)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
@@ -195,8 +205,6 @@
|
||||
"summaryTrend": "{label}: latest {latest} {arrow}",
|
||||
"summaryAdherence": "Medication adherence: {rate}% (weighted mean).",
|
||||
"chartsTitle": "Trend charts",
|
||||
"aiSummaryTitle": "AI summary — not clinically validated",
|
||||
"aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
|
||||
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.",
|
||||
"wellnessTitle": "Wellness summary",
|
||||
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
|
||||
@@ -4437,10 +4445,6 @@
|
||||
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
|
||||
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
|
||||
"stepUpNeeded": "Confirm your second factor, then try again."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
@@ -5303,34 +5307,17 @@
|
||||
"practiceName": "Practice (optional)",
|
||||
"practiceNamePlaceholder": "e.g. Dr. Smith",
|
||||
"includedData": "Included data",
|
||||
"groupVitals": "Vital signs",
|
||||
"groupCardio": "Heart & fitness",
|
||||
"groupActivity": "Activity & sleep",
|
||||
"weight": "Weight",
|
||||
"bp": "Blood pressure",
|
||||
"pulse": "Pulse",
|
||||
"oxygenSaturation": "Oxygen saturation",
|
||||
"bodyFat": "Body fat",
|
||||
"bodyComposition": "Body composition",
|
||||
"restingHeartRate": "Resting heart rate",
|
||||
"hrv": "Heart rate variability",
|
||||
"vo2max": "VO₂max",
|
||||
"steps": "Steps",
|
||||
"distance": "Distance",
|
||||
"sleep": "Sleep",
|
||||
"glucose": "Blood glucose",
|
||||
"medications": "Medications & adherence",
|
||||
"mood": "Mood (off by default — sensitive data)",
|
||||
"bmi": "BMI",
|
||||
"includeCharts": "Embed trend charts (PDF only)",
|
||||
"includeAiSummary": "Append AI summary (not clinically validated)",
|
||||
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
|
||||
"generate": "Generate",
|
||||
"error": "Export failed ({code}).",
|
||||
"labs": "Lab results",
|
||||
"allergies": "Allergies & intolerances",
|
||||
"familyHistory": "Family history",
|
||||
"documentsLink": "Documents for this period"
|
||||
"documentsLink": "Documents for this period",
|
||||
"range30": "Last 30 days",
|
||||
"rangeCustom": "Custom period",
|
||||
"rangeCustomStart": "From",
|
||||
"rangeCustomEnd": "To",
|
||||
"errorGeneric": "The export could not be generated. Please try again.",
|
||||
"errorRateLimit": "You have reached the export limit for this hour.",
|
||||
"errorModuleDisabled": "The health record export is switched off for this account."
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
|
||||
@@ -5344,9 +5331,6 @@
|
||||
"expiry": "Expires in (days)",
|
||||
"expiryHint": "At most {max} days.",
|
||||
"expiryInvalid": "Expiry must be between 1 and {max} days.",
|
||||
"fhirApi": "Allow scoped FHIR API",
|
||||
"fhirApiHint": "Let the link serve a read-only FHIR endpoint in addition to the page view.",
|
||||
"resourceTypes": "FHIR resource types",
|
||||
"create": "Create link",
|
||||
"tokenCreated": "Share link created",
|
||||
"copied": "Copied to clipboard.",
|
||||
@@ -5384,7 +5368,12 @@
|
||||
"pickerEmpty": "No documents found.",
|
||||
"pickerDone": "Done",
|
||||
"pickerSelectedCount": "{count} of {max} selected",
|
||||
"pickerMaxReached": "You've reached the maximum of {max} documents."
|
||||
"pickerMaxReached": "You've reached the maximum of {max} documents.",
|
||||
"scopeTitle": "What this link contains",
|
||||
"scopeInsuranceExcluded": "Insurance details are never shared through a link.",
|
||||
"statusNeedsReselect": "Pick contents again",
|
||||
"reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
|
||||
"reselectAction": "Create a replacement"
|
||||
},
|
||||
"genderOther": "Other",
|
||||
"cycleTracking": {
|
||||
@@ -6920,7 +6909,6 @@
|
||||
"period": "Reporting period: {start} – {end}",
|
||||
"expires": "This link expires on {date}.",
|
||||
"provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.",
|
||||
"vitals": "Vital signs",
|
||||
"labs": "Lab values",
|
||||
"medications": "Medications & adherence",
|
||||
"bmi": "Body mass index (BMI)",
|
||||
@@ -6960,7 +6948,10 @@
|
||||
"download": "Download",
|
||||
"attachmentHint": "This file downloads to your device (no in-page preview).",
|
||||
"exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images."
|
||||
}
|
||||
},
|
||||
"downloadPdf": "Download as PDF",
|
||||
"downloadFhir": "Download as FHIR",
|
||||
"bmiSection": "Body mass index"
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -8867,5 +8858,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
"groupIdentity": "Personal details",
|
||||
"groupVitals": "Vital signs",
|
||||
"groupCardio": "Heart & circulation",
|
||||
"groupBody": "Body measurements",
|
||||
"groupSleepRecovery": "Sleep & recovery",
|
||||
"groupActivity": "Activity",
|
||||
"groupMobility": "Mobility & gait",
|
||||
"groupEnvironment": "Environment",
|
||||
"groupGlucose": "Blood glucose",
|
||||
"groupLabs": "Lab results",
|
||||
"groupMedications": "Medications",
|
||||
"groupHistory": "Conditions & allergies",
|
||||
"groupSensitive": "Sensitive data",
|
||||
"leafPatientIdentity": "Name, date of birth, gender, height",
|
||||
"leafInsurance": "Insurer and insurance number",
|
||||
"leafGlucosePanel": "Glucose panel (time in range, GMI, variability)",
|
||||
"leafLabResults": "Lab results",
|
||||
"leafMedicationList": "Medication list",
|
||||
"leafMedicationAdministrations": "Every logged dose",
|
||||
"leafMedicationCompliance": "Adherence rate",
|
||||
"leafGlp1Therapy": "GLP-1 therapy (dose history, side effects)",
|
||||
"leafAllergies": "Allergies and intolerances",
|
||||
"leafIllnessEpisodes": "Illness episodes",
|
||||
"leafFamilyHistory": "Family history",
|
||||
"leafMood": "Mood",
|
||||
"leafCycle": "Menstrual cycle",
|
||||
"templateStandard": "Standard doctor's report",
|
||||
"applyStandard": "Apply {template}",
|
||||
"standardHint": "One click fills in what a general visit usually needs. Every entry stays editable.",
|
||||
"groupCount": "{on}/{total}",
|
||||
"groupDisclosure": "Show the entries in {group}",
|
||||
"sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
|
||||
"scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
|
||||
"scopeCount": "{count} entries from {groups} areas",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
}
|
||||
}
|
||||
+67
-51
@@ -59,7 +59,9 @@
|
||||
"duplicate_timestamp": "Ya existe una medición para esta hora."
|
||||
},
|
||||
"mood": {
|
||||
"list": { "invalid": "Los filtros de la lista de ánimo no son válidos." },
|
||||
"list": {
|
||||
"invalid": "Los filtros de la lista de ánimo no son válidos."
|
||||
},
|
||||
"create": {
|
||||
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
|
||||
},
|
||||
@@ -99,12 +101,18 @@
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"disclaimer": { "invalid": "Confirma de nuevo el aviso médico." },
|
||||
"tour": { "invalid": "No se pudo guardar el progreso del recorrido." },
|
||||
"disclaimer": {
|
||||
"invalid": "Confirma de nuevo el aviso médico."
|
||||
},
|
||||
"tour": {
|
||||
"invalid": "No se pudo guardar el progreso del recorrido."
|
||||
},
|
||||
"complete": {
|
||||
"invalid": "Comprueba los datos del perfil e inténtalo de nuevo."
|
||||
},
|
||||
"user": { "notFound": "No se pudo encontrar tu cuenta." },
|
||||
"user": {
|
||||
"notFound": "No se pudo encontrar tu cuenta."
|
||||
},
|
||||
"step": {
|
||||
"rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.",
|
||||
"invalid": "No se pudo guardar este paso de la introducción.",
|
||||
@@ -162,16 +170,6 @@
|
||||
"footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
|
||||
"footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
|
||||
"footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}",
|
||||
"typeWeight": "Peso corporal",
|
||||
"typeBpSys": "Tensión arterial sistólica",
|
||||
"typeBpDia": "Tensión arterial diastólica",
|
||||
"typePulse": "Pulso en reposo",
|
||||
"typeBodyFat": "Grasa corporal",
|
||||
"typeSleep": "Duración del sueño",
|
||||
"typeSteps": "Actividad (pasos)",
|
||||
"typeTotalBodyWater": "Agua corporal total",
|
||||
"typeBoneMass": "Masa ósea",
|
||||
"typeOxygenSaturation": "Saturación de oxígeno",
|
||||
"unitSteps": "Pasos",
|
||||
"bmiUnderweight": "Peso insuficiente",
|
||||
"bmiNormal": "Peso normal",
|
||||
@@ -207,8 +205,6 @@
|
||||
"summaryTrend": "{label}: último {latest} {arrow}",
|
||||
"summaryAdherence": "Adherencia a la medicación: {rate}% (media ponderada).",
|
||||
"chartsTitle": "Gráficos de tendencia",
|
||||
"aiSummaryTitle": "Resumen de IA — no validado clínicamente",
|
||||
"aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
|
||||
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.",
|
||||
"wellnessTitle": "Resumen de bienestar",
|
||||
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
|
||||
@@ -4449,10 +4445,6 @@
|
||||
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
|
||||
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
|
||||
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
@@ -5315,34 +5307,17 @@
|
||||
"practiceName": "Consulta (opcional)",
|
||||
"practiceNamePlaceholder": "p. ej. Dr. García",
|
||||
"includedData": "Datos incluidos",
|
||||
"groupVitals": "Signos vitales",
|
||||
"groupCardio": "Corazón y forma física",
|
||||
"groupActivity": "Actividad y sueño",
|
||||
"weight": "Peso",
|
||||
"bp": "Presión arterial",
|
||||
"pulse": "Pulso",
|
||||
"oxygenSaturation": "Saturación de oxígeno",
|
||||
"bodyFat": "Grasa corporal",
|
||||
"bodyComposition": "Composición corporal",
|
||||
"restingHeartRate": "Pulso en reposo",
|
||||
"hrv": "Variabilidad de la frecuencia cardíaca",
|
||||
"vo2max": "VO₂máx",
|
||||
"steps": "Pasos",
|
||||
"distance": "Distancia",
|
||||
"sleep": "Sueño",
|
||||
"glucose": "Glucosa en sangre",
|
||||
"medications": "Medicamentos y adherencia",
|
||||
"mood": "Estado de ánimo (desactivado por defecto — datos sensibles)",
|
||||
"bmi": "IMC",
|
||||
"includeCharts": "Incrustar gráficos de tendencia (solo PDF)",
|
||||
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
|
||||
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
|
||||
"generate": "Generar",
|
||||
"error": "Error en la exportación ({code}).",
|
||||
"labs": "Resultados de análisis",
|
||||
"allergies": "Alergias e intolerancias",
|
||||
"familyHistory": "Antecedentes familiares",
|
||||
"documentsLink": "Documentos del período"
|
||||
"documentsLink": "Documentos del período",
|
||||
"range30": "Últimos 30 días",
|
||||
"rangeCustom": "Periodo personalizado",
|
||||
"rangeCustomStart": "Desde",
|
||||
"rangeCustomEnd": "Hasta",
|
||||
"errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
|
||||
"errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
|
||||
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta."
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
|
||||
@@ -5356,9 +5331,6 @@
|
||||
"expiry": "Caduca en (días)",
|
||||
"expiryHint": "Como máximo {max} días.",
|
||||
"expiryInvalid": "La caducidad debe estar entre 1 y {max} días.",
|
||||
"fhirApi": "Permitir API FHIR acotada",
|
||||
"fhirApiHint": "Deja que el enlace sirva un endpoint FHIR de solo lectura además de la vista de página.",
|
||||
"resourceTypes": "Tipos de recurso FHIR",
|
||||
"create": "Crear enlace",
|
||||
"tokenCreated": "Enlace para compartir creado",
|
||||
"copied": "Copiado al portapapeles.",
|
||||
@@ -5396,7 +5368,12 @@
|
||||
"pickerEmpty": "No se encontraron documentos.",
|
||||
"pickerDone": "Listo",
|
||||
"pickerSelectedCount": "{count} de {max} seleccionados",
|
||||
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos."
|
||||
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos.",
|
||||
"scopeTitle": "Qué contiene este enlace",
|
||||
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
|
||||
"statusNeedsReselect": "Elegir contenido de nuevo",
|
||||
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.",
|
||||
"reselectAction": "Crear un reemplazo"
|
||||
},
|
||||
"genderOther": "Otro",
|
||||
"cycleTracking": {
|
||||
@@ -6932,7 +6909,6 @@
|
||||
"period": "Período del informe: {start} – {end}",
|
||||
"expires": "Este enlace caduca el {date}.",
|
||||
"provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.",
|
||||
"vitals": "Signos vitales",
|
||||
"labs": "Valores de laboratorio",
|
||||
"medications": "Medicamentos y adherencia",
|
||||
"bmi": "Índice de masa corporal (IMC)",
|
||||
@@ -6972,7 +6948,10 @@
|
||||
"download": "Descargar",
|
||||
"attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).",
|
||||
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas."
|
||||
}
|
||||
},
|
||||
"downloadPdf": "Descargar como PDF",
|
||||
"downloadFhir": "Descargar como FHIR",
|
||||
"bmiSection": "Índice de masa corporal"
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -8879,5 +8858,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
"groupIdentity": "Datos personales",
|
||||
"groupVitals": "Signos vitales",
|
||||
"groupCardio": "Corazón y circulación",
|
||||
"groupBody": "Medidas corporales",
|
||||
"groupSleepRecovery": "Sueño y recuperación",
|
||||
"groupActivity": "Actividad",
|
||||
"groupMobility": "Movilidad y marcha",
|
||||
"groupEnvironment": "Entorno",
|
||||
"groupGlucose": "Glucosa en sangre",
|
||||
"groupLabs": "Resultados de laboratorio",
|
||||
"groupMedications": "Medicamentos",
|
||||
"groupHistory": "Antecedentes y alergias",
|
||||
"groupSensitive": "Datos sensibles",
|
||||
"leafPatientIdentity": "Nombre, fecha de nacimiento, sexo, altura",
|
||||
"leafInsurance": "Aseguradora y número de seguro",
|
||||
"leafGlucosePanel": "Panel de glucosa (tiempo en rango, GMI, variabilidad)",
|
||||
"leafLabResults": "Resultados de laboratorio",
|
||||
"leafMedicationList": "Lista de medicamentos",
|
||||
"leafMedicationAdministrations": "Cada dosis registrada",
|
||||
"leafMedicationCompliance": "Adherencia",
|
||||
"leafGlp1Therapy": "Terapia GLP-1 (historial de dosis, efectos secundarios)",
|
||||
"leafAllergies": "Alergias e intolerancias",
|
||||
"leafIllnessEpisodes": "Episodios de enfermedad",
|
||||
"leafFamilyHistory": "Antecedentes familiares",
|
||||
"leafMood": "Estado de ánimo",
|
||||
"leafCycle": "Ciclo menstrual",
|
||||
"templateStandard": "Informe médico estándar",
|
||||
"applyStandard": "Aplicar {template}",
|
||||
"standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.",
|
||||
"groupCount": "{on}/{total}",
|
||||
"groupDisclosure": "Mostrar las entradas de {group}",
|
||||
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
|
||||
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
|
||||
"scopeCount": "{count} entradas de {groups} áreas",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
}
|
||||
}
|
||||
+64
-50
@@ -62,8 +62,12 @@
|
||||
"list": {
|
||||
"invalid": "Les filtres de la liste d’humeur ne sont pas valides."
|
||||
},
|
||||
"create": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
|
||||
"update": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
|
||||
"create": {
|
||||
"invalid": "Vérifiez l’entrée d’humeur et réessayez."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Vérifiez l’entrée d’humeur et réessayez."
|
||||
},
|
||||
"duplicate_timestamp": "Une entrée d’humeur existe déjà pour cette heure.",
|
||||
"not_found": "Cette entrée d’humeur n’est plus disponible.",
|
||||
"ratedFactor": {
|
||||
@@ -106,7 +110,9 @@
|
||||
"complete": {
|
||||
"invalid": "Vérifiez les informations du profil et réessayez."
|
||||
},
|
||||
"user": { "notFound": "Votre compte est introuvable." },
|
||||
"user": {
|
||||
"notFound": "Votre compte est introuvable."
|
||||
},
|
||||
"step": {
|
||||
"rateLimited": "Trop de tentatives. Patientez puis réessayez.",
|
||||
"invalid": "Cette étape d’accueil n’a pas pu être enregistrée.",
|
||||
@@ -164,16 +170,6 @@
|
||||
"footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
|
||||
"footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
|
||||
"footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}",
|
||||
"typeWeight": "Poids corporel",
|
||||
"typeBpSys": "Tension artérielle systolique",
|
||||
"typeBpDia": "Tension artérielle diastolique",
|
||||
"typePulse": "Pouls au repos",
|
||||
"typeBodyFat": "Masse grasse",
|
||||
"typeSleep": "Durée du sommeil",
|
||||
"typeSteps": "Activité (pas)",
|
||||
"typeTotalBodyWater": "Eau corporelle totale",
|
||||
"typeBoneMass": "Masse osseuse",
|
||||
"typeOxygenSaturation": "Saturation en oxygène",
|
||||
"unitSteps": "Pas",
|
||||
"bmiUnderweight": "Insuffisance pondérale",
|
||||
"bmiNormal": "Poids normal",
|
||||
@@ -209,8 +205,6 @@
|
||||
"summaryTrend": "{label} : dernier {latest} {arrow}",
|
||||
"summaryAdherence": "Observance médicamenteuse : {rate}% (moyenne pondérée).",
|
||||
"chartsTitle": "Graphiques de tendance",
|
||||
"aiSummaryTitle": "Résumé IA — non validé cliniquement",
|
||||
"aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
|
||||
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.",
|
||||
"wellnessTitle": "Résumé de bien-être",
|
||||
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
|
||||
@@ -4451,10 +4445,6 @@
|
||||
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
|
||||
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
|
||||
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
@@ -5317,34 +5307,17 @@
|
||||
"practiceName": "Cabinet (facultatif)",
|
||||
"practiceNamePlaceholder": "p. ex. Dr Dupont",
|
||||
"includedData": "Données incluses",
|
||||
"groupVitals": "Signes vitaux",
|
||||
"groupCardio": "Cœur & forme",
|
||||
"groupActivity": "Activité & sommeil",
|
||||
"weight": "Poids",
|
||||
"bp": "Tension artérielle",
|
||||
"pulse": "Pouls",
|
||||
"oxygenSaturation": "Saturation en oxygène",
|
||||
"bodyFat": "Masse grasse",
|
||||
"bodyComposition": "Composition corporelle",
|
||||
"restingHeartRate": "Pouls au repos",
|
||||
"hrv": "Variabilité de la fréquence cardiaque",
|
||||
"vo2max": "VO₂max",
|
||||
"steps": "Pas",
|
||||
"distance": "Distance",
|
||||
"sleep": "Sommeil",
|
||||
"glucose": "Glycémie",
|
||||
"medications": "Médicaments & observance",
|
||||
"mood": "Humeur (désactivé par défaut — données sensibles)",
|
||||
"bmi": "IMC",
|
||||
"includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)",
|
||||
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
|
||||
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
|
||||
"generate": "Générer",
|
||||
"error": "Échec de l’export ({code}).",
|
||||
"labs": "Résultats d'analyses",
|
||||
"allergies": "Allergies et intolérances",
|
||||
"familyHistory": "Antécédents familiaux",
|
||||
"documentsLink": "Documents de la période"
|
||||
"documentsLink": "Documents de la période",
|
||||
"range30": "30 derniers jours",
|
||||
"rangeCustom": "Période personnalisée",
|
||||
"rangeCustomStart": "Du",
|
||||
"rangeCustomEnd": "Au",
|
||||
"errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
|
||||
"errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
|
||||
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte."
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
|
||||
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
|
||||
@@ -5358,9 +5331,6 @@
|
||||
"expiry": "Expire dans (jours)",
|
||||
"expiryHint": "Au plus {max} jours.",
|
||||
"expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.",
|
||||
"fhirApi": "Autoriser l'API FHIR restreinte",
|
||||
"fhirApiHint": "Laissez le lien servir un point d'accès FHIR en lecture seule en plus de la vue page.",
|
||||
"resourceTypes": "Types de ressource FHIR",
|
||||
"create": "Créer le lien",
|
||||
"tokenCreated": "Lien de partage créé",
|
||||
"copied": "Copié dans le presse-papiers.",
|
||||
@@ -5398,7 +5368,12 @@
|
||||
"pickerEmpty": "Aucun document trouvé.",
|
||||
"pickerDone": "Terminé",
|
||||
"pickerSelectedCount": "{count} sur {max} sélectionnés",
|
||||
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents."
|
||||
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents.",
|
||||
"scopeTitle": "Ce que contient ce lien",
|
||||
"scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
|
||||
"statusNeedsReselect": "Choisir à nouveau le contenu",
|
||||
"reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
|
||||
"reselectAction": "Créer un remplacement"
|
||||
},
|
||||
"genderOther": "Autre",
|
||||
"cycleTracking": {
|
||||
@@ -6934,7 +6909,6 @@
|
||||
"period": "Période couverte : {start} – {end}",
|
||||
"expires": "Ce lien expire le {date}.",
|
||||
"provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.",
|
||||
"vitals": "Signes vitaux",
|
||||
"labs": "Valeurs de laboratoire",
|
||||
"medications": "Médicaments et observance",
|
||||
"bmi": "Indice de masse corporelle (IMC)",
|
||||
@@ -6974,7 +6948,10 @@
|
||||
"download": "Télécharger",
|
||||
"attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).",
|
||||
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées."
|
||||
}
|
||||
},
|
||||
"downloadPdf": "Télécharger en PDF",
|
||||
"downloadFhir": "Télécharger en FHIR",
|
||||
"bmiSection": "Indice de masse corporelle"
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -8881,5 +8858,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
"groupIdentity": "Données personnelles",
|
||||
"groupVitals": "Signes vitaux",
|
||||
"groupCardio": "Cœur et circulation",
|
||||
"groupBody": "Mensurations",
|
||||
"groupSleepRecovery": "Sommeil et récupération",
|
||||
"groupActivity": "Activité",
|
||||
"groupMobility": "Mobilité et marche",
|
||||
"groupEnvironment": "Environnement",
|
||||
"groupGlucose": "Glycémie",
|
||||
"groupLabs": "Résultats de laboratoire",
|
||||
"groupMedications": "Médicaments",
|
||||
"groupHistory": "Antécédents et allergies",
|
||||
"groupSensitive": "Données sensibles",
|
||||
"leafPatientIdentity": "Nom, date de naissance, sexe, taille",
|
||||
"leafInsurance": "Assurance et numéro d'assuré",
|
||||
"leafGlucosePanel": "Bilan glycémique (temps dans la cible, GMI, variabilité)",
|
||||
"leafLabResults": "Résultats de laboratoire",
|
||||
"leafMedicationList": "Liste des médicaments",
|
||||
"leafMedicationAdministrations": "Chaque prise enregistrée",
|
||||
"leafMedicationCompliance": "Observance",
|
||||
"leafGlp1Therapy": "Traitement GLP-1 (historique des doses, effets indésirables)",
|
||||
"leafAllergies": "Allergies et intolérances",
|
||||
"leafIllnessEpisodes": "Épisodes de maladie",
|
||||
"leafFamilyHistory": "Antécédents familiaux",
|
||||
"leafMood": "Humeur",
|
||||
"leafCycle": "Cycle menstruel",
|
||||
"templateStandard": "Compte rendu médical standard",
|
||||
"applyStandard": "Appliquer {template}",
|
||||
"standardHint": "Un clic remplit ce dont une consultation générale a généralement besoin. Chaque élément reste modifiable.",
|
||||
"groupCount": "{on}/{total}",
|
||||
"groupDisclosure": "Afficher les éléments de {group}",
|
||||
"sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
|
||||
"scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
|
||||
"scopeCount": "{count} éléments issus de {groups} domaines",
|
||||
"scopeSensitive": "dont {names}"
|
||||
}
|
||||
}
|
||||
+79
-55
@@ -53,13 +53,21 @@
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"create": { "invalid": "Controlla i dati della misurazione e riprova." },
|
||||
"create": {
|
||||
"invalid": "Controlla i dati della misurazione e riprova."
|
||||
},
|
||||
"duplicate_timestamp": "Esiste già una misurazione per questo orario."
|
||||
},
|
||||
"mood": {
|
||||
"list": { "invalid": "I filtri dell’elenco dell’umore non sono validi." },
|
||||
"create": { "invalid": "Controlla la voce dell’umore e riprova." },
|
||||
"update": { "invalid": "Controlla la voce dell’umore e riprova." },
|
||||
"list": {
|
||||
"invalid": "I filtri dell’elenco dell’umore non sono validi."
|
||||
},
|
||||
"create": {
|
||||
"invalid": "Controlla la voce dell’umore e riprova."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Controlla la voce dell’umore e riprova."
|
||||
},
|
||||
"duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.",
|
||||
"not_found": "Questa voce dell’umore non è più disponibile.",
|
||||
"ratedFactor": {
|
||||
@@ -76,7 +84,9 @@
|
||||
"biomarker": {
|
||||
"notFound": "Questo biomarcatore non è più disponibile.",
|
||||
"duplicate": "Esiste già un biomarcatore con questo nome.",
|
||||
"create": { "invalid": "Controlla i dati del biomarcatore e riprova." },
|
||||
"create": {
|
||||
"invalid": "Controlla i dati del biomarcatore e riprova."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Controlla i dati del biomarcatore e riprova.",
|
||||
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
|
||||
@@ -91,12 +101,18 @@
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"disclaimer": { "invalid": "Conferma nuovamente l’avviso medico." },
|
||||
"disclaimer": {
|
||||
"invalid": "Conferma nuovamente l’avviso medico."
|
||||
},
|
||||
"tour": {
|
||||
"invalid": "Non è stato possibile salvare l’avanzamento del tour."
|
||||
},
|
||||
"complete": { "invalid": "Controlla i dati del profilo e riprova." },
|
||||
"user": { "notFound": "Impossibile trovare il tuo account." },
|
||||
"complete": {
|
||||
"invalid": "Controlla i dati del profilo e riprova."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Impossibile trovare il tuo account."
|
||||
},
|
||||
"step": {
|
||||
"rateLimited": "Troppi tentativi. Attendi e riprova.",
|
||||
"invalid": "Non è stato possibile salvare questo passaggio introduttivo.",
|
||||
@@ -154,16 +170,6 @@
|
||||
"footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
|
||||
"footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
|
||||
"footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}",
|
||||
"typeWeight": "Peso corporeo",
|
||||
"typeBpSys": "Pressione arteriosa sistolica",
|
||||
"typeBpDia": "Pressione arteriosa diastolica",
|
||||
"typePulse": "Pulsazioni a riposo",
|
||||
"typeBodyFat": "Massa grassa",
|
||||
"typeSleep": "Durata del sonno",
|
||||
"typeSteps": "Attività (passi)",
|
||||
"typeTotalBodyWater": "Acqua corporea totale",
|
||||
"typeBoneMass": "Massa ossea",
|
||||
"typeOxygenSaturation": "Saturazione di ossigeno",
|
||||
"unitSteps": "Passi",
|
||||
"bmiUnderweight": "Sottopeso",
|
||||
"bmiNormal": "Peso normale",
|
||||
@@ -199,8 +205,6 @@
|
||||
"summaryTrend": "{label}: ultimo {latest} {arrow}",
|
||||
"summaryAdherence": "Aderenza ai farmaci: {rate}% (media ponderata).",
|
||||
"chartsTitle": "Grafici di tendenza",
|
||||
"aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
|
||||
"aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
|
||||
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.",
|
||||
"wellnessTitle": "Riepilogo del benessere",
|
||||
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
|
||||
@@ -4441,10 +4445,6 @@
|
||||
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
|
||||
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
|
||||
"stepUpNeeded": "Conferma il secondo fattore e riprova."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
@@ -5307,34 +5307,17 @@
|
||||
"practiceName": "Studio (facoltativo)",
|
||||
"practiceNamePlaceholder": "es. Dr. Rossi",
|
||||
"includedData": "Dati inclusi",
|
||||
"groupVitals": "Parametri vitali",
|
||||
"groupCardio": "Cuore & fitness",
|
||||
"groupActivity": "Attività & sonno",
|
||||
"weight": "Peso",
|
||||
"bp": "Pressione sanguigna",
|
||||
"pulse": "Polso",
|
||||
"oxygenSaturation": "Saturazione di ossigeno",
|
||||
"bodyFat": "Massa grassa",
|
||||
"bodyComposition": "Composizione corporea",
|
||||
"restingHeartRate": "Frequenza a riposo",
|
||||
"hrv": "Variabilità della frequenza cardiaca",
|
||||
"vo2max": "VO₂max",
|
||||
"steps": "Passi",
|
||||
"distance": "Distanza",
|
||||
"sleep": "Sonno",
|
||||
"glucose": "Glicemia",
|
||||
"medications": "Farmaci e aderenza",
|
||||
"mood": "Umore (disattivato di default — dati sensibili)",
|
||||
"bmi": "BMI",
|
||||
"includeCharts": "Incorpora grafici di tendenza (solo PDF)",
|
||||
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
|
||||
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
|
||||
"generate": "Genera",
|
||||
"error": "Esportazione non riuscita ({code}).",
|
||||
"labs": "Risultati degli esami",
|
||||
"allergies": "Allergie e intolleranze",
|
||||
"familyHistory": "Anamnesi familiare",
|
||||
"documentsLink": "Documenti del periodo"
|
||||
"documentsLink": "Documenti del periodo",
|
||||
"range30": "Ultimi 30 giorni",
|
||||
"rangeCustom": "Periodo personalizzato",
|
||||
"rangeCustomStart": "Dal",
|
||||
"rangeCustomEnd": "Al",
|
||||
"errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
|
||||
"errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
|
||||
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account."
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
|
||||
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
|
||||
@@ -5348,9 +5331,6 @@
|
||||
"expiry": "Scade tra (giorni)",
|
||||
"expiryHint": "Al massimo {max} giorni.",
|
||||
"expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.",
|
||||
"fhirApi": "Consenti API FHIR limitata",
|
||||
"fhirApiHint": "Lascia che il link offra un endpoint FHIR di sola lettura oltre alla vista della pagina.",
|
||||
"resourceTypes": "Tipi di risorsa FHIR",
|
||||
"create": "Crea link",
|
||||
"tokenCreated": "Link di condivisione creato",
|
||||
"copied": "Copiato negli appunti.",
|
||||
@@ -5388,7 +5368,12 @@
|
||||
"pickerEmpty": "Nessun documento trovato.",
|
||||
"pickerDone": "Fatto",
|
||||
"pickerSelectedCount": "{count} di {max} selezionati",
|
||||
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti."
|
||||
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti.",
|
||||
"scopeTitle": "Cosa contiene questo link",
|
||||
"scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
|
||||
"statusNeedsReselect": "Scegli di nuovo il contenuto",
|
||||
"reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
|
||||
"reselectAction": "Crea un sostituto"
|
||||
},
|
||||
"genderOther": "Altro",
|
||||
"cycleTracking": {
|
||||
@@ -6924,7 +6909,6 @@
|
||||
"period": "Periodo del report: {start} – {end}",
|
||||
"expires": "Questo link scade il {date}.",
|
||||
"provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.",
|
||||
"vitals": "Parametri vitali",
|
||||
"labs": "Valori di laboratorio",
|
||||
"medications": "Farmaci e aderenza",
|
||||
"bmi": "Indice di massa corporea (IMC)",
|
||||
@@ -6964,7 +6948,10 @@
|
||||
"download": "Scarica",
|
||||
"attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).",
|
||||
"exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise."
|
||||
}
|
||||
},
|
||||
"downloadPdf": "Scarica come PDF",
|
||||
"downloadFhir": "Scarica come FHIR",
|
||||
"bmiSection": "Indice di massa corporea"
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -8871,5 +8858,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
"groupIdentity": "Dati anagrafici",
|
||||
"groupVitals": "Parametri vitali",
|
||||
"groupCardio": "Cuore e circolazione",
|
||||
"groupBody": "Misure corporee",
|
||||
"groupSleepRecovery": "Sonno e recupero",
|
||||
"groupActivity": "Attività",
|
||||
"groupMobility": "Mobilità e andatura",
|
||||
"groupEnvironment": "Ambiente",
|
||||
"groupGlucose": "Glicemia",
|
||||
"groupLabs": "Esami di laboratorio",
|
||||
"groupMedications": "Farmaci",
|
||||
"groupHistory": "Patologie e allergie",
|
||||
"groupSensitive": "Dati sensibili",
|
||||
"leafPatientIdentity": "Nome, data di nascita, sesso, altezza",
|
||||
"leafInsurance": "Assicurazione e numero di iscrizione",
|
||||
"leafGlucosePanel": "Pannello glicemico (tempo nel range, GMI, variabilità)",
|
||||
"leafLabResults": "Esami di laboratorio",
|
||||
"leafMedicationList": "Elenco dei farmaci",
|
||||
"leafMedicationAdministrations": "Ogni dose registrata",
|
||||
"leafMedicationCompliance": "Aderenza",
|
||||
"leafGlp1Therapy": "Terapia GLP-1 (storico dosi, effetti collaterali)",
|
||||
"leafAllergies": "Allergie e intolleranze",
|
||||
"leafIllnessEpisodes": "Episodi di malattia",
|
||||
"leafFamilyHistory": "Anamnesi familiare",
|
||||
"leafMood": "Umore",
|
||||
"leafCycle": "Ciclo mestruale",
|
||||
"templateStandard": "Referto medico standard",
|
||||
"applyStandard": "Applica {template}",
|
||||
"standardHint": "Un clic compila ciò che serve di solito a una visita generale. Ogni voce resta modificabile.",
|
||||
"groupCount": "{on}/{total}",
|
||||
"groupDisclosure": "Mostra le voci di {group}",
|
||||
"sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
|
||||
"scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
|
||||
"scopeCount": "{count} voci da {groups} aree",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
}
|
||||
}
|
||||
+88
-58
@@ -53,13 +53,21 @@
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." },
|
||||
"create": {
|
||||
"invalid": "Sprawdź dane pomiaru i spróbuj ponownie."
|
||||
},
|
||||
"duplicate_timestamp": "Pomiar dla tego czasu już istnieje."
|
||||
},
|
||||
"mood": {
|
||||
"list": { "invalid": "Filtry listy nastroju są nieprawidłowe." },
|
||||
"create": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
|
||||
"update": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
|
||||
"list": {
|
||||
"invalid": "Filtry listy nastroju są nieprawidłowe."
|
||||
},
|
||||
"create": {
|
||||
"invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
|
||||
},
|
||||
"duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.",
|
||||
"not_found": "Ten wpis nastroju nie jest już dostępny.",
|
||||
"ratedFactor": {
|
||||
@@ -67,12 +75,18 @@
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"create": { "invalid": "Sprawdź wynik badania i spróbuj ponownie." },
|
||||
"result": { "notFound": "Ten wynik badania nie jest już dostępny." },
|
||||
"create": {
|
||||
"invalid": "Sprawdź wynik badania i spróbuj ponownie."
|
||||
},
|
||||
"result": {
|
||||
"notFound": "Ten wynik badania nie jest już dostępny."
|
||||
},
|
||||
"biomarker": {
|
||||
"notFound": "Ten biomarker nie jest już dostępny.",
|
||||
"duplicate": "Biomarker o tej nazwie już istnieje.",
|
||||
"create": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." },
|
||||
"create": {
|
||||
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie.",
|
||||
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
|
||||
@@ -87,10 +101,18 @@
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"disclaimer": { "invalid": "Potwierdź ponownie informację medyczną." },
|
||||
"tour": { "invalid": "Nie udało się zapisać postępu prezentacji." },
|
||||
"complete": { "invalid": "Sprawdź dane profilu i spróbuj ponownie." },
|
||||
"user": { "notFound": "Nie znaleziono Twojego konta." },
|
||||
"disclaimer": {
|
||||
"invalid": "Potwierdź ponownie informację medyczną."
|
||||
},
|
||||
"tour": {
|
||||
"invalid": "Nie udało się zapisać postępu prezentacji."
|
||||
},
|
||||
"complete": {
|
||||
"invalid": "Sprawdź dane profilu i spróbuj ponownie."
|
||||
},
|
||||
"user": {
|
||||
"notFound": "Nie znaleziono Twojego konta."
|
||||
},
|
||||
"step": {
|
||||
"rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.",
|
||||
"invalid": "Nie udało się zapisać tego kroku wprowadzenia.",
|
||||
@@ -148,16 +170,6 @@
|
||||
"footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
|
||||
"footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
|
||||
"footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}",
|
||||
"typeWeight": "Masa ciała",
|
||||
"typeBpSys": "Ciśnienie skurczowe",
|
||||
"typeBpDia": "Ciśnienie rozkurczowe",
|
||||
"typePulse": "Tętno spoczynkowe",
|
||||
"typeBodyFat": "Tkanka tłuszczowa",
|
||||
"typeSleep": "Czas snu",
|
||||
"typeSteps": "Aktywność (kroki)",
|
||||
"typeTotalBodyWater": "Całkowita woda w organizmie",
|
||||
"typeBoneMass": "Masa kostna",
|
||||
"typeOxygenSaturation": "Saturacja tlenem",
|
||||
"unitSteps": "Kroki",
|
||||
"bmiUnderweight": "Niedowaga",
|
||||
"bmiNormal": "Waga prawidłowa",
|
||||
@@ -193,8 +205,6 @@
|
||||
"summaryTrend": "{label}: ostatnio {latest} {arrow}",
|
||||
"summaryAdherence": "Przestrzeganie zaleceń lekowych: {rate}% (średnia ważona).",
|
||||
"chartsTitle": "Wykresy trendów",
|
||||
"aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
|
||||
"aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
|
||||
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.",
|
||||
"wellnessTitle": "Podsumowanie dobrostanu",
|
||||
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
|
||||
@@ -4435,10 +4445,6 @@
|
||||
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
|
||||
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
|
||||
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
"description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza."
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
@@ -5301,34 +5307,17 @@
|
||||
"practiceName": "Gabinet (opcjonalnie)",
|
||||
"practiceNamePlaceholder": "np. dr Kowalski",
|
||||
"includedData": "Uwzględnione dane",
|
||||
"groupVitals": "Parametry życiowe",
|
||||
"groupCardio": "Serce i kondycja",
|
||||
"groupActivity": "Aktywność i sen",
|
||||
"weight": "Waga",
|
||||
"bp": "Ciśnienie krwi",
|
||||
"pulse": "Tętno",
|
||||
"oxygenSaturation": "Saturacja tlenem",
|
||||
"bodyFat": "Tkanka tłuszczowa",
|
||||
"bodyComposition": "Skład ciała",
|
||||
"restingHeartRate": "Tętno spoczynkowe",
|
||||
"hrv": "Zmienność rytmu serca",
|
||||
"vo2max": "VO₂max",
|
||||
"steps": "Kroki",
|
||||
"distance": "Dystans",
|
||||
"sleep": "Sen",
|
||||
"glucose": "Glukoza we krwi",
|
||||
"medications": "Leki i przestrzeganie zaleceń",
|
||||
"mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)",
|
||||
"bmi": "BMI",
|
||||
"includeCharts": "Osadź wykresy trendów (tylko PDF)",
|
||||
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
|
||||
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
|
||||
"generate": "Generuj",
|
||||
"error": "Eksport nie powiódł się ({code}).",
|
||||
"labs": "Wyniki badań",
|
||||
"allergies": "Alergie i nietolerancje",
|
||||
"familyHistory": "Wywiad rodzinny",
|
||||
"documentsLink": "Dokumenty z tego okresu"
|
||||
"documentsLink": "Dokumenty z tego okresu",
|
||||
"range30": "Ostatnie 30 dni",
|
||||
"rangeCustom": "Własny okres",
|
||||
"rangeCustomStart": "Od",
|
||||
"rangeCustomEnd": "Do",
|
||||
"errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
|
||||
"errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
|
||||
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta."
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
|
||||
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
|
||||
@@ -5342,9 +5331,6 @@
|
||||
"expiry": "Wygasa za (dni)",
|
||||
"expiryHint": "Najwyżej {max} dni.",
|
||||
"expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.",
|
||||
"fhirApi": "Zezwól na ograniczone API FHIR",
|
||||
"fhirApiHint": "Pozwól, aby link udostępniał punkt końcowy FHIR tylko do odczytu obok widoku strony.",
|
||||
"resourceTypes": "Typy zasobów FHIR",
|
||||
"create": "Utwórz link",
|
||||
"tokenCreated": "Link udostępniania utworzony",
|
||||
"copied": "Skopiowano do schowka.",
|
||||
@@ -5382,7 +5368,12 @@
|
||||
"pickerEmpty": "Nie znaleziono dokumentów.",
|
||||
"pickerDone": "Gotowe",
|
||||
"pickerSelectedCount": "Wybrano {count} z {max}",
|
||||
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów."
|
||||
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów.",
|
||||
"scopeTitle": "Co zawiera ten link",
|
||||
"scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
|
||||
"statusNeedsReselect": "Wybierz zawartość ponownie",
|
||||
"reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
|
||||
"reselectAction": "Utwórz zamiennik"
|
||||
},
|
||||
"genderOther": "Inna",
|
||||
"cycleTracking": {
|
||||
@@ -6918,7 +6909,6 @@
|
||||
"period": "Okres raportu: {start} – {end}",
|
||||
"expires": "Ten link wygasa dnia {date}.",
|
||||
"provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.",
|
||||
"vitals": "Parametry życiowe",
|
||||
"labs": "Wyniki laboratoryjne",
|
||||
"medications": "Leki i przestrzeganie zaleceń",
|
||||
"bmi": "Wskaźnik masy ciała (BMI)",
|
||||
@@ -6958,7 +6948,10 @@
|
||||
"download": "Pobierz",
|
||||
"attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).",
|
||||
"exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP."
|
||||
}
|
||||
},
|
||||
"downloadPdf": "Pobierz jako PDF",
|
||||
"downloadFhir": "Pobierz jako FHIR",
|
||||
"bmiSection": "Wskaźnik masy ciała"
|
||||
},
|
||||
"cycle": {
|
||||
"symptomCategory": {
|
||||
@@ -8865,5 +8858,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
"groupIdentity": "Dane osobowe",
|
||||
"groupVitals": "Parametry życiowe",
|
||||
"groupCardio": "Serce i krążenie",
|
||||
"groupBody": "Wymiary ciała",
|
||||
"groupSleepRecovery": "Sen i regeneracja",
|
||||
"groupActivity": "Aktywność",
|
||||
"groupMobility": "Mobilność i chód",
|
||||
"groupEnvironment": "Otoczenie",
|
||||
"groupGlucose": "Poziom glukozy",
|
||||
"groupLabs": "Wyniki badań",
|
||||
"groupMedications": "Leki",
|
||||
"groupHistory": "Choroby i alergie",
|
||||
"groupSensitive": "Dane wrażliwe",
|
||||
"leafPatientIdentity": "Imię, data urodzenia, płeć, wzrost",
|
||||
"leafInsurance": "Ubezpieczyciel i numer ubezpieczenia",
|
||||
"leafGlucosePanel": "Panel glukozy (czas w zakresie, GMI, zmienność)",
|
||||
"leafLabResults": "Wyniki badań",
|
||||
"leafMedicationList": "Lista leków",
|
||||
"leafMedicationAdministrations": "Każda zarejestrowana dawka",
|
||||
"leafMedicationCompliance": "Przestrzeganie zaleceń",
|
||||
"leafGlp1Therapy": "Terapia GLP-1 (historia dawek, działania niepożądane)",
|
||||
"leafAllergies": "Alergie i nietolerancje",
|
||||
"leafIllnessEpisodes": "Epizody chorobowe",
|
||||
"leafFamilyHistory": "Wywiad rodzinny",
|
||||
"leafMood": "Nastrój",
|
||||
"leafCycle": "Cykl menstruacyjny",
|
||||
"templateStandard": "Standardowy raport lekarski",
|
||||
"applyStandard": "Zastosuj {template}",
|
||||
"standardHint": "Jedno kliknięcie wypełnia to, czego zwykle wymaga wizyta u lekarza rodzinnego. Każdą pozycję można potem zmienić.",
|
||||
"groupCount": "{on}/{total}",
|
||||
"groupDisclosure": "Pokaż pozycje w {group}",
|
||||
"sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
|
||||
"scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
|
||||
"scopeCount": "{count} pozycji z {groups} obszarów",
|
||||
"scopeSensitive": "w tym {names}"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.38",
|
||||
"version": "1.32.39",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- The owner's saved report selection: the leaf-id inclusion list plus the
|
||||
-- format / range / charts choices the export panel restores.
|
||||
ALTER TABLE "users" ADD COLUMN "report_selection_json" JSONB;
|
||||
|
||||
-- Retire the orphaned flat prefs column. No code outside its own route ever
|
||||
-- read it — the route's docblock claimed the aggregator consulted it, which
|
||||
-- was not true — and the route is removed in the same release.
|
||||
ALTER TABLE "users" DROP COLUMN "doctor_report_prefs_json";
|
||||
|
||||
-- Retire the share-link FHIR scope columns. The face they described was never
|
||||
-- built and their controls are gone. An inert column with a plausible name is
|
||||
-- precisely what a later contributor rewires by mistake.
|
||||
ALTER TABLE "clinician_share_links" DROP COLUMN "resource_types";
|
||||
ALTER TABLE "clinician_share_links" DROP COLUMN "allow_fhir_api";
|
||||
|
||||
-- Revoke every live RECORD share link minted before the selection model. Their
|
||||
-- frozen scope is `{}` or a flat prefs blob, both of which resolved to
|
||||
-- "everything except mood and cycle" — a scope nobody chose. Downgrading them
|
||||
-- to something narrower would still be a scope nobody chose, so they close and
|
||||
-- the owner re-mints from the sharing list, which shows each one in a
|
||||
-- pick-again state with its label, window and expiry preserved.
|
||||
--
|
||||
-- Documents-only shares are untouched: they carry an explicit empty report
|
||||
-- scope and serve no health metric.
|
||||
UPDATE "clinician_share_links"
|
||||
SET "revoked_at" = NOW()
|
||||
WHERE "revoked_at" IS NULL
|
||||
AND "document_only" = false
|
||||
AND "expires_at" > NOW();
|
||||
+21
-21
@@ -491,19 +491,22 @@ model User {
|
||||
// `MoodReminderDispatch` table (one row per user + local date).
|
||||
moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled")
|
||||
|
||||
// v1.4.25 W6c — Doctor-report section toggles.
|
||||
// Shape:
|
||||
// { "bp": boolean,
|
||||
// "weight": boolean,
|
||||
// "pulse": boolean,
|
||||
// "bmi": boolean,
|
||||
// "mood": boolean, // privacy-sensitive; default OFF
|
||||
// "compliance": boolean,
|
||||
// "sleep": boolean }
|
||||
// Null = documented defaults (every section ON except mood). Mood is
|
||||
// opt-in by default — the API layer drops mood entirely from the
|
||||
// payload when the toggle is off so the data never leaves the DB row.
|
||||
doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
|
||||
// The owner's saved report selection: the leaf-id inclusion list plus the
|
||||
// format / range / charts choices the export panel restores. Shape:
|
||||
// { "v": 2,
|
||||
// "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …],
|
||||
// "format": "pdf" | "fhir" | "package",
|
||||
// "rangeDays": number,
|
||||
// "includeCharts": boolean }
|
||||
// Null = never saved, which resolves to the EMPTY selection everywhere.
|
||||
// Read by the export panel, the share-link create form, the FHIR REST face
|
||||
// and the MCP doctor-visit surfaces.
|
||||
//
|
||||
// Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not
|
||||
// express "not chosen": an absent key resolved to included, so a caller who
|
||||
// said nothing received the whole record. Membership is the whole contract
|
||||
// here — absence is exclusion, and there is no defaults constant to drift.
|
||||
reportSelectionJson Json? @map("report_selection_json")
|
||||
|
||||
// v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
|
||||
//
|
||||
@@ -5350,8 +5353,8 @@ model PushAttempt {
|
||||
// `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is
|
||||
// returned to the owner exactly once at creation and never persisted.
|
||||
//
|
||||
// The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and
|
||||
// `resourceTypes` are write-once and a share can NEVER widen later — only
|
||||
// The scope is FROZEN at creation: `rangeStart` and `sectionsJson` are
|
||||
// write-once and a share can NEVER widen later — only
|
||||
// `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This
|
||||
// mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is
|
||||
// mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no
|
||||
@@ -5378,13 +5381,10 @@ model ClinicianShareLink {
|
||||
rangeStart DateTime @map("range_start")
|
||||
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
|
||||
rangeEnd DateTime? @map("range_end")
|
||||
/// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON.
|
||||
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
|
||||
/// mint, never widened. A blob this build cannot read resolves to the empty
|
||||
/// selection, so a drifted row serves nothing rather than defaulting open.
|
||||
sectionsJson Json @map("sections_json")
|
||||
/// FHIR resource types this link may serve (a subset of the catalogue),
|
||||
/// e.g. `["Observation","MedicationStatement"]`.
|
||||
resourceTypes String[] @map("resource_types")
|
||||
/// Whether the FHIR API is reachable via this link at all (vs the view only).
|
||||
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
|
||||
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
|
||||
/// create. When true the public view serves ONLY the attached documents and
|
||||
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.32.38";
|
||||
/* @sw-version-fallback */ "v1.32.39";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Guard for the converted-value precision class (issue #627, second half).
|
||||
*
|
||||
* Every entry in the display-transform registry declares the `decimals` its
|
||||
* unit is read at. Before v1.32.39 that number was carried all the way to the
|
||||
* render layer and read by exactly one screen out of all the ones that show a
|
||||
* converted value, so everywhere else the raw product of a kilogram and
|
||||
* 2.20462262185 reached the surface unrounded. The fix moved the rounding into
|
||||
* the conversion helpers, which means a surface gets a correctly-scaled number
|
||||
* whether or not it remembers to ask.
|
||||
*
|
||||
* This file guards the two ways that could be undone:
|
||||
*
|
||||
* 1. A PROPERTY over the whole registry — every branch, swept over a spread
|
||||
* of raw values, must come out already at its declared grain, and a
|
||||
* non-rescaling branch must come out byte-identical. A property test is
|
||||
* the honest shape here: the thing being guaranteed is arithmetic, and a
|
||||
* structural sweep for "did the surface remember to round" is exactly the
|
||||
* per-call-site discipline this change exists to abolish.
|
||||
* 2. A STRUCTURAL freeze on the two ways to get an unrounded converted value
|
||||
* anyway — importing the documented escape hatch, or hand-rolling
|
||||
* `value * transform.factor + transform.offset` at a call site. Both sets
|
||||
* are pinned to a named allowlist, so a new converted-value surface
|
||||
* cannot quietly join them.
|
||||
*/
|
||||
import { readFileSync, globSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getDisplayTransform,
|
||||
transformRescales,
|
||||
TRANSFORMED_TYPES,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** The registry itself owns the arithmetic; it is not a call site. */
|
||||
const REGISTRY = "lib/measurements/display-transform.ts";
|
||||
|
||||
/**
|
||||
* The one module allowed to import the unrounded escape hatch. The target
|
||||
* adapter needs the unrounded pound edge of a canonical guardrail so it can
|
||||
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
|
||||
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
|
||||
*/
|
||||
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
|
||||
|
||||
/**
|
||||
* The surfaces allowed to read a transform's raw `factor` / `offset`. All four
|
||||
* do it for the same reason: the chart takes the multiplier as a prop, folds
|
||||
* it into its own series at the single read boundary, and formats the result
|
||||
* itself. Everything those files RENDER directly goes through the rounding
|
||||
* helpers. Adding a file here means arguing that its number never reaches a
|
||||
* screen unrounded.
|
||||
*/
|
||||
const RAW_SCALE_SURFACES = [
|
||||
"app/page-client.tsx",
|
||||
"app/insights/weight/page.tsx",
|
||||
"components/insights/healthkit-metric-page.tsx",
|
||||
"components/insights/device-score-tile.tsx",
|
||||
];
|
||||
|
||||
/** Matches a `factor` / `offset` read off a display transform. */
|
||||
const RAW_SCALE_READ =
|
||||
/\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/;
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC }).filter(
|
||||
(rel) =>
|
||||
// Never walk the Prisma client, and skip the suites that legitimately
|
||||
// exercise the raw arithmetic they are asserting about.
|
||||
!rel.startsWith("generated/") && !rel.includes("__tests__/"),
|
||||
);
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("display-transform rounding guard — the registry property", () => {
|
||||
const RAW_VALUES = [
|
||||
0, 1, 1.3, 10, 36.6, 36.66666, 37, 78.45, 83.99981845627636, 84, 95.25, 210,
|
||||
1609.344, 8046.72, -0.5, -12.3456789,
|
||||
];
|
||||
|
||||
it("sweeps a non-empty registry", () => {
|
||||
// Guards against a set that silently empties out (a green void).
|
||||
expect(TRANSFORMED_TYPES.size).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("declares a usable decimal count on every branch", () => {
|
||||
for (const type of TRANSFORMED_TYPES) {
|
||||
for (const preference of ["metric", "imperial"] as const) {
|
||||
const { decimals } = getDisplayTransform(type, preference);
|
||||
expect(Number.isInteger(decimals)).toBe(true);
|
||||
expect(decimals).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never returns a converted value finer than its declared decimals", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const type of TRANSFORMED_TYPES) {
|
||||
for (const preference of ["metric", "imperial"] as const) {
|
||||
const transform = getDisplayTransform(type, preference);
|
||||
if (!transformRescales(transform)) continue;
|
||||
const scale = 10 ** transform.decimals;
|
||||
for (const raw of RAW_VALUES) {
|
||||
for (const [label, shown] of [
|
||||
["absolute", applyDisplayTransform(raw, transform)],
|
||||
["delta", applyDisplayTransformDelta(raw, transform)],
|
||||
] as const) {
|
||||
// Re-rounding at the declared grain must be a no-op: the value
|
||||
// carries no more precision than the transform says it should.
|
||||
if (Math.round(shown * scale) / scale !== shown) {
|
||||
offenders.push(
|
||||
`${type}/${preference} ${label} ${raw} → ${shown}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a non-rescaling branch byte-identical", () => {
|
||||
// The metric branch only relabels the symbol; the number it shows IS the
|
||||
// stored number, and a stored 78.45 kg is not conversion noise.
|
||||
const offenders: string[] = [];
|
||||
for (const type of TRANSFORMED_TYPES) {
|
||||
for (const preference of ["metric", "imperial"] as const) {
|
||||
const transform = getDisplayTransform(type, preference);
|
||||
if (transformRescales(transform)) continue;
|
||||
for (const raw of RAW_VALUES) {
|
||||
if (applyDisplayTransform(raw, transform) !== raw) {
|
||||
offenders.push(`${type}/${preference} absolute ${raw}`);
|
||||
}
|
||||
if (applyDisplayTransformDelta(raw, transform) !== raw) {
|
||||
offenders.push(`${type}/${preference} delta ${raw}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves an untransformed type byte-identical", () => {
|
||||
for (const type of ["PULSE", "BLOOD_GLUCOSE", "BODY_MASS_INDEX"]) {
|
||||
const transform = getDisplayTransform(type, "imperial");
|
||||
for (const raw of RAW_VALUES) {
|
||||
expect(applyDisplayTransform(raw, transform)).toBe(raw);
|
||||
expect(applyDisplayTransformDelta(raw, transform)).toBe(raw);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("display-transform rounding guard — the bypass freeze", () => {
|
||||
it("sweeps a non-empty source tree", () => {
|
||||
expect(sourceFiles().length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("confines the unrounded escape hatch to its documented importers", () => {
|
||||
const importers = sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== REGISTRY &&
|
||||
read(rel).includes("applyDisplayTransformUnrounded"),
|
||||
);
|
||||
expect(importers.sort()).toEqual([...UNROUNDED_IMPORTERS].sort());
|
||||
});
|
||||
|
||||
it("confines hand-rolled transform scaling to the chart-feeding surfaces", () => {
|
||||
const offenders = sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== REGISTRY &&
|
||||
!UNROUNDED_IMPORTERS.includes(rel) &&
|
||||
RAW_SCALE_READ.test(read(rel)),
|
||||
);
|
||||
expect(offenders.sort()).toEqual([...RAW_SCALE_SURFACES].sort());
|
||||
});
|
||||
|
||||
it("every allowlisted surface reads the raw scale to feed a chart", () => {
|
||||
// The justification for holding the raw multiplier at all: the chart
|
||||
// takes it as a prop. A file that stops passing it has no reason to be
|
||||
// reading `.factor` any more and belongs back behind the helpers.
|
||||
// The prop as it is actually passed — a bare substring would still
|
||||
// match a renamed identifier that no longer reaches any chart.
|
||||
const offenders = RAW_SCALE_SURFACES.filter(
|
||||
(rel) => !/\bvalueScale=/.test(read(rel)),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every allowlisted surface still converts its rendered values", () => {
|
||||
// The chart formats its own series; anything these files render outside
|
||||
// the chart must come back through the preference hook.
|
||||
const offenders = RAW_SCALE_SURFACES.filter(
|
||||
(rel) => !read(rel).includes("useUnitDisplay()"),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Structural guard: a rendered scope control with no gating path fails the
|
||||
* build, and a gating path with no control fails it too.
|
||||
*
|
||||
* 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".
|
||||
*
|
||||
* 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 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 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 {
|
||||
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 COLLECT_PATH = join(
|
||||
REPO_ROOT,
|
||||
"src",
|
||||
"lib",
|
||||
"doctor-report",
|
||||
"collect.ts",
|
||||
);
|
||||
const PICKER_PATH = join(
|
||||
REPO_ROOT,
|
||||
"src",
|
||||
"components",
|
||||
"settings",
|
||||
"report-selection",
|
||||
"report-scope-picker.tsx",
|
||||
);
|
||||
|
||||
const collectSource = readFileSync(COLLECT_PATH, "utf8");
|
||||
const pickerSource = readFileSync(PICKER_PATH, "utf8");
|
||||
|
||||
/** 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, "");
|
||||
}
|
||||
|
||||
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("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 leaves cannot be individually withheld, so their control would lie",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
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}"`),
|
||||
);
|
||||
expect(
|
||||
unread,
|
||||
"these structured leaves are never read in the aggregator, so switching " +
|
||||
"them off would change nothing that leaves the instance",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Root layout — i18n boot-script ordering pin.
|
||||
*
|
||||
* The locale catalog is a ~370 KB separate asset, and the client bundle
|
||||
* needs it in hand for its FIRST render or `t()` resolves raw keys against
|
||||
* the server's real text (React error #418, plus a visible frame of
|
||||
* `nav.skipToContent` before the provider backfills).
|
||||
*
|
||||
* A plain `<script defer>` cannot deliver that. Next emits its own chunks
|
||||
* as `async` tags AHEAD of anything the layout renders into `<head>`, and
|
||||
* an `async` script executes the moment its fetch settles — so the catalog
|
||||
* was racing the app bundle and lost about a third of cold loads.
|
||||
* `next/script` at `beforeInteractive` is the only registration Next
|
||||
* awaits before requiring an app module, so it is the one that holds.
|
||||
*
|
||||
* This is a structural pin: reverting the tag to `<script defer>` reads
|
||||
* like a harmless simplification and its damage is intermittent.
|
||||
*/
|
||||
const LAYOUT_PATH = join(process.cwd(), "src/app/layout.tsx");
|
||||
|
||||
describe("root layout — i18n boot script", () => {
|
||||
const src = readFileSync(LAYOUT_PATH, "utf8");
|
||||
|
||||
it("registers the catalog through next/script", () => {
|
||||
expect(src).toContain('from "next/script"');
|
||||
const tag = src.slice(src.indexOf("<Script"));
|
||||
expect(tag).toContain('strategy="beforeInteractive"');
|
||||
expect(tag).toContain("/i18n/${initialLocale}.js");
|
||||
});
|
||||
|
||||
it("carries the CSP nonce", () => {
|
||||
const tag = src.slice(
|
||||
src.indexOf("<Script"),
|
||||
src.indexOf("/>", src.indexOf("<Script")),
|
||||
);
|
||||
expect(tag).toContain("nonce={nonce}");
|
||||
});
|
||||
|
||||
it("never falls back to a bare deferred script tag for the catalog", () => {
|
||||
const bareTag = /<script\b[^>]*\/i18n\//;
|
||||
expect(bareTag.test(src)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/doctor-report-prefs", () => {
|
||||
it("returns defaults when the column is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
|
||||
};
|
||||
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
|
||||
expect(env.data.mood).toBe(false); // privacy default per the maintainer
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
// ── Case 1 — happy path ─────────────────────────────────────────────
|
||||
it("persists the full shape and returns the canonical form", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: false,
|
||||
bmi: true,
|
||||
mood: true,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
cycle: false,
|
||||
labs: true,
|
||||
allergies: true,
|
||||
familyHistory: false,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof body };
|
||||
expect(env.data).toEqual(body);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: body },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 2 — partial update layers over current persisted row ───────
|
||||
it("merges a partial update over the persisted row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
allergies: false,
|
||||
familyHistory: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
// User only flips mood off — every other key keeps its persisted value.
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof current };
|
||||
expect(env.data).toEqual({ ...current, mood: false });
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: { ...current, mood: false } },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 3 — invalid shape ─────────────────────────────────────────
|
||||
it("rejects a non-boolean value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ bp: "nope" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 4 — unauthenticated cross-user request ────────────────────
|
||||
// the maintainer's spec mentions "cross-user 404" — the route layer returns 401
|
||||
// when there is no session at all, which is the only "you are not the
|
||||
// right user" surface available before requireAuth() resolves an
|
||||
// identity. `prisma.user.update` must NOT fire in this case.
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
|
||||
it("writes an audit-log entry with the previous and new pref shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: true,
|
||||
mood: false,
|
||||
compliance: true,
|
||||
sleep: true,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.doctor-report-prefs.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: current,
|
||||
next: expect.objectContaining({ mood: true }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W6c — per-user Doctor-Report section toggles.
|
||||
*
|
||||
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
|
||||
* (defaults when the row is null).
|
||||
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
|
||||
* shape over the current row
|
||||
* and persists the canonical
|
||||
* fully-resolved object so a
|
||||
* future schema migration
|
||||
* doesn't need to back-fill
|
||||
* missing keys.
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The doctor-report aggregator + PDF renderer both read this
|
||||
* row at generation time so mood data is dropped at the aggregator
|
||||
* layer when `mood = false` — the data never leaves the DB.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
doctorReportPrefsSchema,
|
||||
parseDoctorReportPrefs,
|
||||
resolveDoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
|
||||
}
|
||||
|
||||
// Merge the partial input over the current row (or defaults) so a
|
||||
// dialog that only toggles `mood` doesn't have to re-state every other
|
||||
// flag. Persist the canonical fully-resolved object so the column
|
||||
// shape stays stable across future schema additions.
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
const merged = resolveDoctorReportPrefs(
|
||||
current?.doctorReportPrefsJson,
|
||||
parsed.data,
|
||||
);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
doctorReportPrefsJson: toJson(merged),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.25 W10 reconcile (security M-3): record the previous + new
|
||||
// pref shape in the audit log. Doctor-Report pref toggles
|
||||
// (especially `mood: true`) widen the PDF surface; without an
|
||||
// audit trail a silent compromise (or unintended client write)
|
||||
// is invisible. Mirrors the timezone-route audit pattern.
|
||||
await auditLog("user.doctor-report-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: current?.doctorReportPrefsJson ?? null,
|
||||
next: merged,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put" },
|
||||
meta: {
|
||||
bp: merged.bp,
|
||||
weight: merged.weight,
|
||||
pulse: merged.pulse,
|
||||
bmi: merged.bmi,
|
||||
mood: merged.mood,
|
||||
compliance: merged.compliance,
|
||||
sleep: merged.sleep,
|
||||
},
|
||||
});
|
||||
return apiSuccess(merged);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* The owner's saved report selection.
|
||||
*
|
||||
* GET /api/auth/me/report-selection — the saved profile, or `null` when the
|
||||
* account has never saved one.
|
||||
* PUT /api/auth/me/report-selection — replace it.
|
||||
*
|
||||
* Replace, not merge. A selection is a list of what was chosen, so merging a
|
||||
* partial over a stored one would re-introduce exactly the failure this shape
|
||||
* removes: a leaf staying in because the caller did not mention it. The body
|
||||
* states the whole scope or it is not a scope.
|
||||
*
|
||||
* Unknown leaf ids are refused with a 422 that names them. A client sending an
|
||||
* id this build does not know is a contract mismatch the client has to see.
|
||||
*
|
||||
* Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
|
||||
* outside that route ever read.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
|
||||
import { selectionFromRequest } from "@/lib/report-selection/selection";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.report-selection.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { reportSelectionJson: true },
|
||||
});
|
||||
const parsed = savedReportProfileSchema.safeParse(row?.reportSelectionJson);
|
||||
// A drifted or absent blob answers `null`, not a default profile: the panel
|
||||
// then applies the named template and shows it, which is a visible act
|
||||
// rather than a silent server choice.
|
||||
return apiSuccess({ profile: parsed.success ? parsed.data : null });
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "report-selection.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = savedReportProfileSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.report-selection.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "report-selection.body.invalid_shape");
|
||||
}
|
||||
|
||||
const minted = selectionFromRequest(parsed.data);
|
||||
if (!minted.ok) {
|
||||
annotate({
|
||||
action: { name: "auth.me.report-selection.put.invalid" },
|
||||
meta: { unknownLeaves: minted.error.unknownLeaves },
|
||||
});
|
||||
throw new HttpError(422, "report-selection.leaves.unknown");
|
||||
}
|
||||
|
||||
// Persist the canonical ordering rather than the caller's, so two clients
|
||||
// that chose the same scope store the same bytes.
|
||||
const profile = {
|
||||
v: parsed.data.v,
|
||||
leaves: [...minted.selection.leaves],
|
||||
format: parsed.data.format,
|
||||
rangeDays: parsed.data.rangeDays,
|
||||
includeCharts: parsed.data.includeCharts,
|
||||
};
|
||||
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { reportSelectionJson: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { reportSelectionJson: toJson(profile) },
|
||||
});
|
||||
|
||||
// The saved selection is what the FHIR REST face and the MCP doctor-visit
|
||||
// surfaces replay for a caller that cannot ask a human, so widening it
|
||||
// widens those too. That belongs in the audit trail.
|
||||
await auditLog("user.report-selection.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: current?.reportSelectionJson ?? null,
|
||||
next: profile,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.report-selection.put" },
|
||||
meta: { leafCount: profile.leaves.length, format: profile.format },
|
||||
});
|
||||
return apiSuccess({ profile });
|
||||
});
|
||||
@@ -110,6 +110,11 @@ export const GET = apiHandler(async () => {
|
||||
// formatters and the <DateField> primitive.
|
||||
dateFormat: user.dateFormat ?? "AUTO",
|
||||
lastReportPracticeName: user.lastReportPracticeName ?? null,
|
||||
// The owner's saved report selection — the leaf inclusion list plus the
|
||||
// format / range / charts choices the export panel restores. `null` when
|
||||
// the account has never saved one, which is what makes the panel show the
|
||||
// named template on the first run rather than a silent server default.
|
||||
reportSelection: user.reportSelectionJson ?? null,
|
||||
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
|
||||
// column is absent (partial-deploy rollback safety, see migration
|
||||
// 0078 commentary). Every Coach mount point on the client checks
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* 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";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
@@ -23,7 +29,25 @@ 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/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -31,9 +55,15 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
|
||||
}));
|
||||
// Partial: the aggregator's canonical-source collapse calls `userDayKey` from
|
||||
// the same module, so a total mock breaks any fixture that carries rows.
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
|
||||
};
|
||||
});
|
||||
|
||||
// v1.18.0 — the health-record aggregator resolves the per-user module map
|
||||
// so a disabled data-domain module never reaches the export. Stub the gate
|
||||
@@ -50,6 +80,8 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
@@ -62,6 +94,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> = {},
|
||||
@@ -122,19 +164,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);
|
||||
});
|
||||
|
||||
@@ -148,7 +192,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");
|
||||
});
|
||||
@@ -160,7 +204,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);
|
||||
});
|
||||
});
|
||||
@@ -168,7 +212,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();
|
||||
@@ -179,7 +223,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());
|
||||
@@ -188,7 +232,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());
|
||||
@@ -199,7 +245,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.
|
||||
@@ -210,7 +261,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.
|
||||
@@ -228,7 +283,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" },
|
||||
),
|
||||
);
|
||||
@@ -238,10 +297,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 }),
|
||||
@@ -251,53 +312,385 @@ describe("POST /api/export/health-record — outputs", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The selection has to mean the same thing in every artefact.
|
||||
*
|
||||
* 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.
|
||||
* Three defects met here. The export panel rendered eight measurement switches
|
||||
* that the grouped-to-flat fold discarded, so deselecting body fat or VO₂max
|
||||
* exported them anyway. The medication switch documented itself as excluding
|
||||
* all medication data while the drug list and the per-dose ledger went out
|
||||
* unconditionally. And the route fetched allergies and family history without
|
||||
* consulting the toggles at all, so `sections.allergies = false` produced a PDF
|
||||
* without an allergy section and a FHIR bundle carrying every one of them — in
|
||||
* the `package` format, inside the same zip.
|
||||
*
|
||||
* Each case below runs the real route end to end and reads the produced
|
||||
* artefact. Mutation check: revert any single gate and its case goes red while
|
||||
* the positive control beside it stays green, so none of them can pass by
|
||||
* asserting on an empty fixture.
|
||||
*/
|
||||
describe("POST /api/export/health-record — remembered practice name", () => {
|
||||
describe("POST /api/export/health-record — the selection reaches every format", () => {
|
||||
const T0 = new Date("2026-02-01T08:00:00.000Z");
|
||||
|
||||
function measurement(type: string, value: number) {
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
measuredAt: T0,
|
||||
source: "MANUAL",
|
||||
deviceType: null,
|
||||
sleepStage: null,
|
||||
glucoseContext: null,
|
||||
};
|
||||
}
|
||||
|
||||
function allergyRow() {
|
||||
return {
|
||||
id: "allergy-1",
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
severity: "SEVERE",
|
||||
status: "ACTIVE",
|
||||
onsetAt: null,
|
||||
reactionEncrypted: null,
|
||||
notesEncrypted: null,
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
};
|
||||
}
|
||||
|
||||
function familyHistoryRow() {
|
||||
return {
|
||||
id: "fmh-1",
|
||||
relationship: "MOTHER",
|
||||
condition: "Hypertension",
|
||||
ageAtOnset: 52,
|
||||
notesEncrypted: null,
|
||||
createdAt: T0,
|
||||
updatedAt: T0,
|
||||
};
|
||||
}
|
||||
|
||||
function medicationRow() {
|
||||
return {
|
||||
id: "med-1",
|
||||
name: "Sample medication",
|
||||
dose: "5 mg",
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
deliveryForm: null,
|
||||
active: true,
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Resource types present in the bundle a given selection produces. */
|
||||
async function bundleResourceTypes(selection: {
|
||||
v: number;
|
||||
leaves: string[];
|
||||
}): Promise<string[]> {
|
||||
const { POST } = await import("../route");
|
||||
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 } }[];
|
||||
};
|
||||
return bundle.entry.map((e) => e.resource.resourceType);
|
||||
}
|
||||
|
||||
/** The LOINC/HealthKit codes of every Observation a selection produces. */
|
||||
async function observationCodes(selection: {
|
||||
v: number;
|
||||
leaves: string[];
|
||||
}): Promise<string[]> {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(mkReq({ format: "fhir", selection }));
|
||||
expect(res.status).toBe(200);
|
||||
const bundle = (await res.json()) as {
|
||||
entry: {
|
||||
resource: {
|
||||
resourceType: string;
|
||||
code?: { coding?: { code: string }[]; text?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
return bundle.entry
|
||||
.filter((e) => e.resource.resourceType === "Observation")
|
||||
.flatMap((e) => [
|
||||
...(e.resource.code?.coding?.map((c) => c.code) ?? []),
|
||||
e.resource.code?.text ?? "",
|
||||
]);
|
||||
}
|
||||
|
||||
it("honours every previously-dead measurement switch in the FHIR bundle", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
measurement("BODY_FAT", 21),
|
||||
measurement("OXYGEN_SATURATION", 97),
|
||||
measurement("BONE_MASS", 3.1),
|
||||
measurement("RESTING_HEART_RATE", 54),
|
||||
measurement("HEART_RATE_VARIABILITY", 61),
|
||||
measurement("VO2_MAX", 44),
|
||||
measurement("ACTIVITY_STEPS", 8400),
|
||||
measurement("WALKING_RUNNING_DISTANCE", 6200),
|
||||
] as never);
|
||||
|
||||
// Positive control: with 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);
|
||||
|
||||
// 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(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 () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
measurement("BODY_FAT", 21),
|
||||
measurement("OXYGEN_SATURATION", 97),
|
||||
] as never);
|
||||
const { POST } = await import("../route");
|
||||
|
||||
const withBoth = await pdfText(
|
||||
await POST(
|
||||
mkReq({
|
||||
format: "pdf",
|
||||
locale: "en",
|
||||
selection: sel("BODY_FAT", "OXYGEN_SATURATION"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(withBoth).toContain("Body Fat");
|
||||
|
||||
const without = await pdfText(
|
||||
await POST(
|
||||
mkReq({
|
||||
format: "pdf",
|
||||
locale: "en",
|
||||
selection: sel("PULSE"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(without).not.toContain("Body Fat");
|
||||
});
|
||||
|
||||
it("holds the mental-health screening totals back unless they are asked for by name", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
measurement("PHQ9_SCORE", 11),
|
||||
measurement("GAD7_SCORE", 8),
|
||||
] as never);
|
||||
|
||||
// The module is on (the default since v1.29.1) and the caller 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", selection: sel() }));
|
||||
const bundle = (await res.json()) as {
|
||||
entry: { resource: { resourceType: string } }[];
|
||||
};
|
||||
expect(
|
||||
bundle.entry.filter((e) => e.resource.resourceType === "Observation"),
|
||||
).toEqual([]);
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
it("lets the medication switch withhold the medication list", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medicationRow(),
|
||||
] as never);
|
||||
|
||||
expect(await bundleResourceTypes(sel("MEDICATION_LIST"))).toContain(
|
||||
"MedicationStatement",
|
||||
);
|
||||
expect(
|
||||
await bundleResourceTypes(sel("MEDICATION_COMPLIANCE")),
|
||||
).not.toContain("MedicationStatement");
|
||||
});
|
||||
|
||||
it("keeps allergies and family history out of the FHIR bundle when deselected", async () => {
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([
|
||||
allergyRow(),
|
||||
] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([
|
||||
familyHistoryRow(),
|
||||
] as never);
|
||||
|
||||
const on = await bundleResourceTypes(sel("ALLERGIES", "FAMILY_HISTORY"));
|
||||
expect(on).toContain("AllergyIntolerance");
|
||||
expect(on).toContain("FamilyMemberHistory");
|
||||
|
||||
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(sel("PULSE"));
|
||||
expect(prisma.allergy.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.familyHistoryEntry.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ships a package whose bundle agrees with its PDF", async () => {
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([
|
||||
allergyRow(),
|
||||
] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([
|
||||
familyHistoryRow(),
|
||||
] as never);
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
mkReq({ format: "package", selection: sel("PULSE") }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const files = unzipSync(new Uint8Array(await res.arrayBuffer()));
|
||||
const bundle = JSON.parse(
|
||||
new TextDecoder().decode(files["bundle.json"]!),
|
||||
) as { entry: { resource: { resourceType: string } }[] };
|
||||
const types = bundle.entry.map((e) => e.resource.resourceType);
|
||||
// The compliant PDF and the violating bundle used to travel together.
|
||||
expect(types).not.toContain("AllergyIntolerance");
|
||||
expect(types).not.toContain("FamilyMemberHistory");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 — 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();
|
||||
});
|
||||
@@ -308,7 +701,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();
|
||||
@@ -316,9 +713,71 @@ 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" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The insurance number is its own leaf, and it is the most sensitive value on
|
||||
* this path: encrypted at rest, and the one identifier a share link may never
|
||||
* carry at all.
|
||||
*
|
||||
* Mutation check: drop the `selection.has("INSURANCE")` condition on the
|
||||
* decrypt and "never decrypts the insurance number" goes red.
|
||||
*/
|
||||
describe("POST /api/export/health-record — the insurance leaf", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "sample",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
glucoseUnit: null,
|
||||
thresholdsJson: null,
|
||||
fullName: null,
|
||||
insurerName: "Sample Insurer",
|
||||
insurerIkNumber: "123456789",
|
||||
insuranceNumberEncrypted: Buffer.from("ciphertext"),
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("never decrypts the insurance number when the leaf was not chosen", async () => {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
mkReq({ format: "fhir", selection: sel("WEIGHT", "PATIENT_IDENTITY") }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Not decrypted, so it never entered this process's memory — the stronger
|
||||
// statement than "it was decrypted and then left out".
|
||||
expect(decrypt).not.toHaveBeenCalled();
|
||||
const bundle = (await res.json()) as {
|
||||
entry: { resource: { resourceType: string } }[];
|
||||
};
|
||||
expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain(
|
||||
"Coverage",
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts and carries it when the leaf was chosen by name", async () => {
|
||||
const { POST } = await import("../route");
|
||||
const res = await POST(
|
||||
mkReq({
|
||||
format: "fhir",
|
||||
selection: sel("WEIGHT", "PATIENT_IDENTITY", "INSURANCE"),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(decrypt).toHaveBeenCalled();
|
||||
const text = JSON.stringify(await res.json());
|
||||
expect(text).toContain("A123456789");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,25 @@
|
||||
/**
|
||||
* POST /api/export/health-record — v1.7.0 flagship health-record export.
|
||||
* POST /api/export/health-record — the doctor-handover artefact.
|
||||
*
|
||||
* One entry point that produces the doctor-handover artefact in one of three
|
||||
* formats, driven by a strict Zod selection payload:
|
||||
* - `pdf` → `application/pdf` (enhanced clinical report)
|
||||
* One entry point producing one of three formats from one aggregated payload,
|
||||
* driven by a strict Zod selection:
|
||||
* - `pdf` → `application/pdf`
|
||||
* - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle)
|
||||
* - `package` → `application/zip` (PDF + FHIR + README in one download)
|
||||
* - `package` → `application/zip` (PDF + Bundle + README)
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h) — so structured + PDF
|
||||
* + CSV exports cannot be parallelised past the cap.
|
||||
* Audit: `health-record.export` (records format/days/sections/charts — never
|
||||
* the values).
|
||||
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
||||
* Audit: `health-record.export` — format, window, and the chosen leaf ids,
|
||||
* never the values behind them.
|
||||
*
|
||||
* Strict validation: unlike the legacy doctor-report route (which tolerates
|
||||
* drift), this route fails loudly — `.strict()` + `returnAllZodIssues` returns
|
||||
* a 422 multi-issue envelope on any unknown key (including any attempt to
|
||||
* smuggle a `userId`, which is always narrowed from `requireAuth()`).
|
||||
* The `selection` field is required and carries the leaf inclusion list. An
|
||||
* unknown leaf id is a 422 that names it; the old grouped `sections` shape is
|
||||
* a 422 too, because accepting it would mean choosing a scope the caller did
|
||||
* not express. The rendering itself lives in
|
||||
* `src/lib/export/health-record-artefacts.ts`; this route keeps the HTTP
|
||||
* concerns.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import {
|
||||
@@ -40,39 +40,46 @@ import {
|
||||
normaliseDateRange,
|
||||
sanitisePracticeName,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/build-bundle";
|
||||
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
|
||||
import { selectionFromRequest } from "@/lib/report-selection/selection";
|
||||
import {
|
||||
buildFhirDocumentBundle,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
exportSelectionSchema,
|
||||
toDoctorReportPrefs,
|
||||
} from "@/lib/validations/health-record-export";
|
||||
buildBundleJson,
|
||||
buildPackageZip,
|
||||
buildPdfBytes,
|
||||
type ArtefactInputs,
|
||||
} from "@/lib/export/health-record-artefacts";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* Remember the practice / clinic name for the next report, so the user does
|
||||
* not retype it every time (`User.lastReportPracticeName`, the preference the
|
||||
* doctor-report contract has always promised).
|
||||
* Remember what the owner chose, so the panel opens carrying it next time.
|
||||
*
|
||||
* Called only once the requested artefact has actually been produced: a
|
||||
* generation that throws must not overwrite the remembered name with a
|
||||
* half-typed one. `null` is a legitimate value — the user cleared the line and
|
||||
* the next cover should stay cleared. The value is the already-sanitised one
|
||||
* (`sanitisePracticeName`, 120-char cap), and the update names its single
|
||||
* field explicitly rather than spreading the parsed selection.
|
||||
* generation that throws must not overwrite the remembered scope or a
|
||||
* half-typed practice name. `null` is a legitimate practice name — the user
|
||||
* cleared the line and the next cover should stay cleared. Both fields are
|
||||
* named explicitly rather than spread from the parsed body.
|
||||
*/
|
||||
async function rememberPracticeName(
|
||||
async function rememberChoices(
|
||||
userId: string,
|
||||
practiceName: string | null,
|
||||
profile: {
|
||||
v: 2;
|
||||
leaves: string[];
|
||||
format: "pdf" | "fhir" | "package";
|
||||
rangeDays: number;
|
||||
includeCharts: boolean;
|
||||
},
|
||||
) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastReportPracticeName: practiceName },
|
||||
data: {
|
||||
lastReportPracticeName: practiceName,
|
||||
reportSelectionJson: toJson(profile),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,10 +87,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "export.health-record.build" } });
|
||||
|
||||
// v1.18.0 B3 — the whole doctor-report / health-record surface is the
|
||||
// `doctorReport` module. Refuse with a 403 `module.disabled` envelope when
|
||||
// the account turned it off — even with a valid Bearer token. The settings
|
||||
// entry-point already hides client-side; this is the hard enforcement.
|
||||
// The whole doctor-report surface is the `doctorReport` module. Refuse with
|
||||
// a 403 `module.disabled` envelope when the account turned it off — even
|
||||
// with a valid Bearer token.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -92,11 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
// The selection payload is small and bounded — format, range,
|
||||
// section toggles, a few flags, an optional practice-name string.
|
||||
// 64 KB is far above any legitimate selection while still rejecting
|
||||
// a multi-megabyte body before it reaches `JSON.parse`. A DoS
|
||||
// ceiling, not a tight bound.
|
||||
// The selection payload is small and bounded. 64 KB is far above any
|
||||
// legitimate one while still rejecting a multi-megabyte body before it
|
||||
// reaches `JSON.parse`. A DoS ceiling, not a tight bound.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
@@ -106,18 +110,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error);
|
||||
}
|
||||
const selection = parsed.data;
|
||||
const input = parsed.data;
|
||||
|
||||
const range = normaliseDateRange(selection.range ?? undefined);
|
||||
const practiceName = sanitisePracticeName(selection.practiceName);
|
||||
const sections = toDoctorReportPrefs(selection.sections);
|
||||
const includeCharts = selection.includeCharts ?? true;
|
||||
const minted = selectionFromRequest(input.selection);
|
||||
if (!minted.ok) {
|
||||
annotate({ meta: { unknownLeaves: minted.error.unknownLeaves } });
|
||||
return apiError(
|
||||
`Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
|
||||
422,
|
||||
{ errorCode: "export.selection.unknown_leaf" },
|
||||
);
|
||||
}
|
||||
const selection = minted.selection;
|
||||
|
||||
// Locale resolution, most-specific first: the explicit selection wins, then
|
||||
// the in-app `healthlog-locale` cookie (what the user actually chose in the
|
||||
// UI), and only then the browser's Accept-Language header. The header is the
|
||||
// weakest signal — a German user on an English-default browser would
|
||||
// otherwise get an English report.
|
||||
const range = normaliseDateRange(input.range ?? undefined);
|
||||
const practiceName = sanitisePracticeName(input.practiceName);
|
||||
const includeCharts = input.includeCharts ?? true;
|
||||
|
||||
// Locale, most-specific first: the explicit selection wins, then the in-app
|
||||
// cookie (what the user actually chose in the UI), and only then the
|
||||
// browser's Accept-Language header — the weakest signal.
|
||||
const cookieValue = request.cookies.get("healthlog-locale")?.value;
|
||||
const cookieLocale = (locales as readonly string[]).includes(
|
||||
cookieValue ?? "",
|
||||
@@ -125,51 +137,52 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
? (cookieValue as Locale)
|
||||
: null;
|
||||
const locale: Locale =
|
||||
selection.locale ??
|
||||
input.locale ??
|
||||
cookieLocale ??
|
||||
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
|
||||
|
||||
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
|
||||
// German-region locale. The WHO ATC coding is unaffected either way.
|
||||
const germanAtc =
|
||||
selection.germanAtc ??
|
||||
input.germanAtc ??
|
||||
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
|
||||
|
||||
const [data, userTz, userRow, allergyRows, familyHistoryRows] =
|
||||
await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
collectDoctorReportData(user.id, range, selection, { practiceName }),
|
||||
resolveUserTimezone(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
insuranceNumberEncrypted: true,
|
||||
insightsCachedText: true,
|
||||
timeFormat: true,
|
||||
},
|
||||
}),
|
||||
// v1.25 (W-RECORDS) — structured records are always-available reference
|
||||
// data (not time-windowed), so they ride alongside the report rather
|
||||
// than through `collectDoctorReportData`. Owner-scoped, live rows only.
|
||||
prisma.allergy.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { insuranceNumberEncrypted: true, timeFormat: true },
|
||||
}),
|
||||
// Structured records are always-available reference data (not
|
||||
// time-windowed), so they ride alongside the report rather than through
|
||||
// the aggregator. Owner-scoped, live rows only — and each read is gated
|
||||
// on its own leaf, so a deselected section is not loaded, not merely
|
||||
// dropped later.
|
||||
selection.has("ALLERGIES")
|
||||
? prisma.allergy.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
selection.has("FAMILY_HISTORY")
|
||||
? prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// FHIR records folded into every bundle format (fhir + package).
|
||||
const fhirRecords = {
|
||||
allergies: allergyRows.map(toAllergyDTO),
|
||||
familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
|
||||
};
|
||||
|
||||
// Decrypt the KVNR fail-soft: a key-rotation gap on one row should
|
||||
// never 500 the export — the cover/identifier just omits the value.
|
||||
// The KVNR is encrypted at rest and is its own leaf. It is neither read nor
|
||||
// decrypted unless the insurance leaf was chosen. The decrypt is fail-soft:
|
||||
// a key-rotation gap on one row omits the value rather than 500-ing the
|
||||
// export.
|
||||
let insuranceNumber: string | null = null;
|
||||
if (userRow?.insuranceNumberEncrypted) {
|
||||
if (selection.has("INSURANCE") && userRow?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(userRow.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
@@ -177,43 +190,53 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// AI summary is OUT of the clinical PDF by default — only included when
|
||||
// the user explicitly toggled `includeAiSummary` AND a cached briefing
|
||||
// text exists. Never reaches the FHIR bundle (not a clinical observation).
|
||||
const aiSummary =
|
||||
selection.includeAiSummary && userRow?.insightsCachedText
|
||||
? extractAiSummaryText(userRow.insightsCachedText)
|
||||
: null;
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
const artefact: ArtefactInputs = {
|
||||
data,
|
||||
insuranceNumber,
|
||||
records: fhirRecords,
|
||||
germanAtc,
|
||||
includeCharts,
|
||||
locale,
|
||||
userTz,
|
||||
timeFormat: userRow?.timeFormat ?? "AUTO",
|
||||
t,
|
||||
};
|
||||
|
||||
const profile = {
|
||||
v: 2 as const,
|
||||
leaves: [...selection.leaves],
|
||||
format: input.format,
|
||||
rangeDays: Math.min(365, Math.max(1, range.days)),
|
||||
includeCharts,
|
||||
};
|
||||
|
||||
await auditLog("health-record.export", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
format: selection.format,
|
||||
format: input.format,
|
||||
days: range.days,
|
||||
startDate: range.start.toISOString(),
|
||||
endDate: range.end.toISOString(),
|
||||
charts: includeCharts,
|
||||
aiSummary: aiSummary !== null,
|
||||
// Section toggles only (booleans) — never the underlying values.
|
||||
sections,
|
||||
// The chosen leaf ids — never the values behind them.
|
||||
leaves: profile.leaves,
|
||||
},
|
||||
});
|
||||
|
||||
if (selection.format === "fhir") {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
fhirRecords,
|
||||
);
|
||||
const json = JSON.stringify(bundle);
|
||||
await rememberPracticeName(user.id, practiceName);
|
||||
if (input.format === "fhir") {
|
||||
const json = buildBundleJson(artefact);
|
||||
await rememberChoices(user.id, practiceName, profile);
|
||||
annotate({
|
||||
meta: { format: "fhir", bytes: json.length, days: range.days },
|
||||
meta: {
|
||||
format: "fhir",
|
||||
bytes: json.length,
|
||||
days: range.days,
|
||||
leafCount: profile.leaves.length,
|
||||
},
|
||||
});
|
||||
return new NextResponse(json, {
|
||||
status: 200,
|
||||
@@ -225,23 +248,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const pdfBytes = renderDoctorReportPdfBytes(data, {
|
||||
t,
|
||||
locale,
|
||||
userTz,
|
||||
timeFormat: userRow?.timeFormat ?? "AUTO",
|
||||
insuranceNumber,
|
||||
includeCharts,
|
||||
aiSummary,
|
||||
});
|
||||
// Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the
|
||||
// artefact exists from here on.
|
||||
await rememberPracticeName(user.id, practiceName);
|
||||
const pdfBytes = buildPdfBytes(artefact);
|
||||
// Both remaining formats carry the rendered PDF, so the artefact exists from
|
||||
// here on.
|
||||
await rememberChoices(user.id, practiceName, profile);
|
||||
|
||||
if (selection.format === "pdf") {
|
||||
if (input.format === "pdf") {
|
||||
annotate({
|
||||
meta: { format: "pdf", bytes: pdfBytes.byteLength, days: range.days },
|
||||
meta: {
|
||||
format: "pdf",
|
||||
bytes: pdfBytes.byteLength,
|
||||
days: range.days,
|
||||
leafCount: profile.leaves.length,
|
||||
},
|
||||
});
|
||||
const buffer = pdfBytes.slice().buffer;
|
||||
return new NextResponse(buffer, {
|
||||
@@ -255,25 +274,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
fhirRecords,
|
||||
);
|
||||
const readme = t("doctorReport.packageReadme");
|
||||
const zipped = zipSync(
|
||||
{
|
||||
"report.pdf": pdfBytes,
|
||||
"bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
|
||||
"README.txt": strToU8(readme),
|
||||
},
|
||||
{ level: 6 },
|
||||
);
|
||||
const zipped = buildPackageZip(artefact, pdfBytes);
|
||||
annotate({
|
||||
meta: { format: "package", bytes: zipped.byteLength, days: range.days },
|
||||
meta: {
|
||||
format: "package",
|
||||
bytes: zipped.byteLength,
|
||||
days: range.days,
|
||||
leafCount: profile.leaves.length,
|
||||
},
|
||||
});
|
||||
const zipBuffer = zipped.slice().buffer;
|
||||
return new NextResponse(zipBuffer, {
|
||||
@@ -286,26 +294,3 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract plain summary text from the cached insights JSON blob. The cache
|
||||
* is provider-shaped JSON; we pull the human-readable summary field if
|
||||
* present, else fall back to a trimmed stringification. Always plain text
|
||||
* (no markdown rendering — the PDF prints React-free string children).
|
||||
*/
|
||||
function extractAiSummaryText(raw: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(typeof parsed.summary === "string" && parsed.summary) ||
|
||||
(typeof parsed.text === "string" && parsed.text) ||
|
||||
(typeof parsed.briefing === "string" && parsed.briefing) ||
|
||||
null;
|
||||
if (candidate) return candidate.trim().slice(0, 4000);
|
||||
return null;
|
||||
} catch {
|
||||
// Not JSON — treat as plain text.
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 4000) : null;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -53,13 +53,15 @@ import {
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
FHIR_READ_SCOPE,
|
||||
FHIR_REST_RESOURCE_TYPES,
|
||||
FHIR_EVERYTHING_OPERATION,
|
||||
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) },
|
||||
@@ -84,7 +86,7 @@ type CapabilitiesBody = {
|
||||
snomedRoute: string;
|
||||
germanAtcDefaultLocales: string[];
|
||||
restBaseUrl: string;
|
||||
readScope: string;
|
||||
scopeMintable: boolean;
|
||||
resourceTypes: string[];
|
||||
operations: string[];
|
||||
searchParams: string[];
|
||||
@@ -92,8 +94,10 @@ type CapabilitiesBody = {
|
||||
share: {
|
||||
supported: boolean;
|
||||
maxDays: number;
|
||||
fhirApi: boolean;
|
||||
sections: string[];
|
||||
reportDownload: string[];
|
||||
selectionVersion: number;
|
||||
groups: string[];
|
||||
leaves: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -164,7 +168,10 @@ describe("GET /api/meta/capabilities — drift guards", () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(body.data.fhir.restBaseUrl).toBe("/api/fhir");
|
||||
expect(body.data.fhir.readScope).toBe(FHIR_READ_SCOPE);
|
||||
// The face checks `fhir:read`, but nothing mints a token carrying it, so
|
||||
// advertising it as an obtainable credential would be a claim the code
|
||||
// cannot support.
|
||||
expect(body.data.fhir.scopeMintable).toBe(false);
|
||||
expect(body.data.fhir.resourceTypes).toEqual([...FHIR_REST_RESOURCE_TYPES]);
|
||||
expect(body.data.fhir.operations).toEqual([FHIR_EVERYTHING_OPERATION]);
|
||||
expect(body.data.fhir.searchParams).toEqual([...FHIR_SEARCH_PARAMS]);
|
||||
@@ -175,11 +182,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,13 +41,15 @@ import {
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
FHIR_READ_SCOPE,
|
||||
FHIR_REST_RESOURCE_TYPES,
|
||||
FHIR_EVERYTHING_OPERATION,
|
||||
FHIR_SEARCH_PARAMS,
|
||||
} from "@/lib/fhir/rest";
|
||||
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
|
||||
import { exportSectionsSchema } from "@/lib/validations/health-record-export";
|
||||
import {
|
||||
ALL_LEAF_IDS,
|
||||
REPORT_GROUP_ORDER,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -108,22 +110,33 @@ export const GET = apiHandler(async () => {
|
||||
// whole-record operation, the honoured search params and the narrow
|
||||
// Bearer scope. All sourced from the canonical `rest.ts` constants.
|
||||
restBaseUrl: "/api/fhir",
|
||||
readScope: FHIR_READ_SCOPE,
|
||||
// The face checks a narrow `fhir:read` scope, and NO code path mints a
|
||||
// token carrying it — every mint grants a wildcard, the medication
|
||||
// ingest scope, or an MCP scope. So the face is reachable by the owner's
|
||||
// cookie session or a wildcard device token, and by nothing else. It is
|
||||
// reported that way rather than advertising a credential a client cannot
|
||||
// obtain.
|
||||
scopeMintable: false,
|
||||
resourceTypes: FHIR_REST_RESOURCE_TYPES,
|
||||
operations: [FHIR_EVERYTHING_OPERATION],
|
||||
searchParams: FHIR_SEARCH_PARAMS,
|
||||
},
|
||||
// Clinician share-link surface (v1.11): a scoped, time-boxed, revocable
|
||||
// read-only link to the owner's record. Descriptor sourced from the
|
||||
// canonical share validation + export-section constants. The share serves
|
||||
// the rendered record view only; no `/api/fhir/*` route honours a share
|
||||
// token yet (they require an authenticated `fhir:read` Bearer), so the
|
||||
// share→FHIR face is not advertised as live.
|
||||
// Clinician share-link surface: a scoped, time-boxed, revocable read-only
|
||||
// link to the owner's record, sourced from the canonical share validation
|
||||
// and the selection catalogue.
|
||||
//
|
||||
// `reportDownload` replaces the old `fhirApi: false`, which described a
|
||||
// face that was never built. The record IS reachable in machine form now:
|
||||
// as a download under the token, behind the same unlock the page uses,
|
||||
// serving exactly the link's frozen selection. No bearer face, no new
|
||||
// scope — `/api/fhir/*` still refuses a share token.
|
||||
share: {
|
||||
supported: true,
|
||||
maxDays: SHARE_LINK_MAX_DAYS,
|
||||
fhirApi: false,
|
||||
sections: Object.keys(exportSectionsSchema.shape),
|
||||
reportDownload: ["fhir", "pdf"],
|
||||
selectionVersion: 2,
|
||||
groups: REPORT_GROUP_ORDER,
|
||||
leaves: ALL_LEAF_IDS,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -64,8 +67,6 @@ function validBody(overrides: Record<string, unknown> = {}) {
|
||||
label: "Cardiology clinic",
|
||||
rangeStart: "2026-01-01T00:00:00Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: ["Observation"],
|
||||
allowFhirApi: true,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -77,14 +78,14 @@ function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
label: "Cardiology clinic",
|
||||
rangeStart: new Date("2026-01-01T00:00:00Z"),
|
||||
rangeEnd: null,
|
||||
resourceTypes: ["Observation"],
|
||||
allowFhirApi: true,
|
||||
passphraseHash: "hash(STORED)",
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
createdAt: new Date(),
|
||||
revokedAt: null,
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
documentOnly: false,
|
||||
sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT"])),
|
||||
_count: { documents: 0 },
|
||||
...overrides,
|
||||
};
|
||||
@@ -241,22 +242,27 @@ describe("POST /api/share-links — create", () => {
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the retired FHIR scope keys with 422", async () => {
|
||||
// The share link has never served FHIR against its token. The keys
|
||||
// are gone from the create schema rather than accepted and ignored, so a
|
||||
// stale client is told plainly instead of believing it scoped something.
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({ allowFhirApi: true, resourceTypes: ["Observation"] }),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a documents-only share (empty report sections)", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
documentIds: ["doc-a"],
|
||||
}),
|
||||
),
|
||||
storedRow({ _count: { documents: 1 } }) as never,
|
||||
);
|
||||
const res = await POST(postReq(validBody({ documentIds: ["doc-a"] })));
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(1);
|
||||
@@ -267,19 +273,16 @@ describe("POST /api/share-links — create", () => {
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
|
||||
storedRow({ _count: { documents: 1 } }) as never,
|
||||
);
|
||||
|
||||
// A hostile / stale client tries to widen a document link into a record
|
||||
// share: FHIR on, resource types set, sections requested. The server must
|
||||
// ignore every one of them.
|
||||
// share by requesting sections. The server must ignore them.
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({
|
||||
documentOnly: true,
|
||||
allowFhirApi: true,
|
||||
resourceTypes: ["Observation", "Patient"],
|
||||
sections: { vitals: { bp: true }, labs: true },
|
||||
selection: { v: 2, leaves: ["BLOOD_PRESSURE_SYS", "LAB_RESULTS"] },
|
||||
documentIds: ["doc-a"],
|
||||
}),
|
||||
),
|
||||
@@ -288,12 +291,12 @@ describe("POST /api/share-links — create", () => {
|
||||
|
||||
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
||||
.calls[0][0];
|
||||
// Empty FHIR scope, FHIR API off — no health surface at all.
|
||||
expect(createArg.data.resourceTypes).toEqual([]);
|
||||
expect(createArg.data.allowFhirApi).toBe(false);
|
||||
// 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 retired FHIR scope columns are never written at all.
|
||||
expect(createArg.data).not.toHaveProperty("resourceTypes");
|
||||
expect(createArg.data).not.toHaveProperty("allowFhirApi");
|
||||
// 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.
|
||||
@@ -364,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([]);
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,16 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
|
||||
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
import {
|
||||
createShareLinkSchema,
|
||||
SHARE_LINK_FORBIDDEN_LEAVES,
|
||||
} from "@/lib/validations/clinician-share-link";
|
||||
import {
|
||||
EMPTY_REPORT_SELECTION,
|
||||
reportSelectionSchema,
|
||||
selectionFromRequest,
|
||||
selectionToBlob,
|
||||
} from "@/lib/report-selection/selection";
|
||||
import {
|
||||
generatePassphrase,
|
||||
hashPassphrase,
|
||||
@@ -65,9 +73,11 @@ const shareLinkSummarySelect = {
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
documentOnly: true,
|
||||
// Read to answer one owner-facing question: does this link's frozen scope
|
||||
// predate the selection model? Never returned — only the boolean derived
|
||||
// from it is.
|
||||
sectionsJson: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
@@ -77,15 +87,30 @@ const shareLinkSummarySelect = {
|
||||
_count: { select: { documents: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* RETIRED, kept on the response only.
|
||||
*
|
||||
* These two fields described a scoped FHIR face for a share link that was
|
||||
* never built. The request schema refuses them — sending either is a 422 —
|
||||
* their columns are dropped, and nothing reads or honours them. They stay in
|
||||
* the create and list responses, serving the constant `false` and `[]`,
|
||||
* because the native client was given this response shape and a missing
|
||||
* non-optional key fails a whole Swift decode. They come out once #70 in the
|
||||
* native repository confirms the client does not decode them.
|
||||
*/
|
||||
const RETIRED_FHIR_SCOPE_FIELDS = {
|
||||
allowFhirApi: false,
|
||||
resourceTypes: [] as string[],
|
||||
} as const;
|
||||
|
||||
/** Project a stored row to the safe owner-facing shape (never the token). */
|
||||
function toSummary(row: {
|
||||
id: string;
|
||||
label: string;
|
||||
rangeStart: Date;
|
||||
rangeEnd: Date | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
documentOnly: boolean;
|
||||
sectionsJson: unknown;
|
||||
passphraseHash: string | null;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
@@ -99,11 +124,17 @@ function toSummary(row: {
|
||||
label: row.label,
|
||||
rangeStart: row.rangeStart.toISOString(),
|
||||
rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null,
|
||||
resourceTypes: row.resourceTypes,
|
||||
allowFhirApi: row.allowFhirApi,
|
||||
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
|
||||
// gates on the column, not on "are all sections off?").
|
||||
documentOnly: row.documentOnly,
|
||||
// A record link whose frozen scope predates the selection model. The
|
||||
// v1.32.37 migration revoked every one of them, because their scope was
|
||||
// "everything except mood and cycle" — a scope nobody chose. The sharing
|
||||
// list surfaces this so the owner sees why the link closed and can re-mint
|
||||
// with the same label, window and expiry, stopped at the selection step.
|
||||
needsReselection:
|
||||
!row.documentOnly &&
|
||||
!reportSelectionSchema.safeParse(row.sectionsJson).success,
|
||||
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
||||
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
||||
protected: row.passphraseHash !== null,
|
||||
@@ -140,19 +171,41 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
// v1.28.13 — a documents-only share carries NO report scope. Force the scope
|
||||
// columns to empty here (not just at the client) so a document link can never
|
||||
// serve a single health metric, whatever the body tried to smuggle: no
|
||||
// sections (an explicit all-OFF prefs blob, distinct from the `{}` that means
|
||||
// "full record defaults"), no FHIR resource types, FHIR API off. "Share this
|
||||
// document" means the document, not the whole record.
|
||||
// v1.28.13 — a documents-only share carries NO report scope. Force it empty
|
||||
// here (not just at the client) so a document link can never serve a single
|
||||
// health metric, whatever the body tried to smuggle. "Share this document"
|
||||
// means the document, not the whole record.
|
||||
const documentOnly = input.documentOnly === true;
|
||||
const sectionsJson = documentOnly
|
||||
? EMPTY_DOCTOR_REPORT_PREFS
|
||||
: (input.sections ?? {});
|
||||
const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []);
|
||||
const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false);
|
||||
annotate({ meta: { documentOnly } });
|
||||
|
||||
let selection = EMPTY_REPORT_SELECTION;
|
||||
if (!documentOnly && input.selection) {
|
||||
const minted = selectionFromRequest(input.selection);
|
||||
if (!minted.ok) {
|
||||
return apiError(
|
||||
`Unknown selection leaf: ${minted.error.unknownLeaves.join(", ")}`,
|
||||
422,
|
||||
{ errorCode: "share-link.selection.unknown_leaf" },
|
||||
);
|
||||
}
|
||||
selection = minted.selection;
|
||||
}
|
||||
|
||||
// The insurance leaf is refused on a share link. The share view has never
|
||||
// decrypted the insurance number, and refusing it here makes that structural
|
||||
// rather than incidental: there is no path by which a link could carry it.
|
||||
const forbidden = SHARE_LINK_FORBIDDEN_LEAVES.filter((leaf) =>
|
||||
selection.has(leaf),
|
||||
);
|
||||
if (forbidden.length > 0) {
|
||||
return apiError(`A share link cannot carry: ${forbidden.join(", ")}`, 422, {
|
||||
errorCode: "share-link.selection.forbidden_leaf",
|
||||
});
|
||||
}
|
||||
|
||||
const sectionsJson = selectionToBlob(selection);
|
||||
annotate({
|
||||
meta: { documentOnly, leafCount: selection.leaves.length },
|
||||
});
|
||||
|
||||
// Mint the 192-bit raw token, store only its HMAC hash.
|
||||
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
|
||||
@@ -196,8 +249,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
rangeStart: new Date(input.rangeStart),
|
||||
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
|
||||
sectionsJson: sectionsJson as Prisma.InputJsonValue,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
documentOnly,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
@@ -213,8 +264,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
shareLinkId: created.id,
|
||||
resourceTypes: created.resourceTypes,
|
||||
allowFhirApi: created.allowFhirApi,
|
||||
documentCount: created._count.documents,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
@@ -230,6 +279,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess(
|
||||
{
|
||||
...toSummary(created),
|
||||
...RETIRED_FHIR_SCOPE_FIELDS,
|
||||
token: rawToken,
|
||||
passphrase: rawPassphrase,
|
||||
shareUrl,
|
||||
@@ -249,5 +299,10 @@ export const GET = apiHandler(async () => {
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
return apiSuccess({ shareLinks: rows.map(toSummary) });
|
||||
return apiSuccess({
|
||||
shareLinks: rows.map((row) => ({
|
||||
...toSummary(row),
|
||||
...RETIRED_FHIR_SCOPE_FIELDS,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -94,8 +94,6 @@ function resolvedContext() {
|
||||
rangeStart: new Date(),
|
||||
rangeEnd: null,
|
||||
sectionsJson: {},
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* The two machine-format downloads on a share link.
|
||||
*
|
||||
* A practice opens the link, unlocks it, and saves the file into their system.
|
||||
* That is what the removed FHIR toggle promised and never served. The gate has
|
||||
* to be the document route's, step for step — the same live check, the same
|
||||
* token-scoped unlock cookie, a rate limit before any aggregation, and the same
|
||||
* blunt 404 for every miss class, so a probe cannot tell "no such link" from
|
||||
* "revoked" from "documents-only".
|
||||
*
|
||||
* The load-bearing scope property: the download resolves the SAME frozen
|
||||
* selection the page renders from, so it can never be wider than the page.
|
||||
*
|
||||
* Mutation checks:
|
||||
* - remove the unlock-cookie check in `resolveShareReportDownload` →
|
||||
* "refuses a protected link without the unlock cookie" goes red.
|
||||
* - remove the rate-limit check → "refuses over the rate limit" goes red.
|
||||
* - drop the `view.documentOnly` guard → "refuses a documents-only link"
|
||||
* goes red.
|
||||
* - change the bucket key to the document route's `share-documents:` →
|
||||
* "uses its own rate bucket" goes red.
|
||||
* - drop the `auditLog` call → "records an audit row per download" goes red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next/headers", () => ({ cookies: vi.fn() }));
|
||||
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
|
||||
resolveShareGateState: vi.fn(),
|
||||
resolveShareToken: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/clinician-share/unlock-cookie", () => ({
|
||||
unlockCookieName: (hash: string) => `hls_unlock_${hash}`,
|
||||
verifyUnlockValue: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/clinician-share/share-view-data", () => ({
|
||||
loadShareViewData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-translator", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/i18n/server-translator")>();
|
||||
return actual;
|
||||
});
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
resolveShareGateState,
|
||||
resolveShareToken,
|
||||
} from "@/lib/clinician-share/resolve-share-token";
|
||||
import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
|
||||
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
const TOKEN = "hls_deadbeef";
|
||||
|
||||
function report(): DoctorReportData {
|
||||
return {
|
||||
period: {
|
||||
days: 30,
|
||||
since: "2026-01-01",
|
||||
start: "2026-01-01T00:00:00.000Z",
|
||||
end: "2026-01-31T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 4, latest: 79 } },
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], {
|
||||
now: new Date("2026-01-31T00:00:00.000Z"),
|
||||
}),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
wellnessScores: null,
|
||||
} as DoctorReportData;
|
||||
}
|
||||
|
||||
function req(): Request {
|
||||
return new Request(`http://localhost/c/${TOKEN}/fhir`);
|
||||
}
|
||||
|
||||
const params = { params: Promise.resolve({ token: TOKEN }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue({
|
||||
tokenHash: "hash-1",
|
||||
passphraseHash: "pass-hash",
|
||||
} as never);
|
||||
vi.mocked(cookies).mockResolvedValue({
|
||||
get: () => ({ value: "unlock-value" }),
|
||||
} as never);
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(resolveShareToken).mockResolvedValue({
|
||||
shareLinkId: "link-1",
|
||||
ownerUserId: "owner-1",
|
||||
} as never);
|
||||
vi.mocked(loadShareViewData).mockResolvedValue({
|
||||
report: report(),
|
||||
selection: selectionFromLeaves(["WEIGHT"]),
|
||||
documents: [],
|
||||
documentOnly: false,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /c/{token}/fhir", () => {
|
||||
it("serves the link's frozen scope as a document Bundle", async () => {
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("application/fhir+json");
|
||||
expect(res.headers.get("content-disposition")).toContain("attachment");
|
||||
expect(res.headers.get("cache-control")).toBe("no-store");
|
||||
const bundle = (await res.json()) as {
|
||||
resourceType: string;
|
||||
type: string;
|
||||
};
|
||||
expect(bundle.resourceType).toBe("Bundle");
|
||||
expect(bundle.type).toBe("document");
|
||||
});
|
||||
|
||||
it("carries no Coverage — the insurance leaf cannot reach this path", async () => {
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
const bundle = (await res.json()) as {
|
||||
entry: { resource: { resourceType: string } }[];
|
||||
};
|
||||
expect(bundle.entry.map((e) => e.resource.resourceType)).not.toContain(
|
||||
"Coverage",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a protected link without the unlock cookie", async () => {
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(false);
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(loadShareViewData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unknown, revoked or expired token with the same flat 404", async () => {
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue(null as never);
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a documents-only link even if a report came back with it", async () => {
|
||||
// The flag is authoritative on its own. `loadShareViewData` returns a null
|
||||
// report alongside it today, so the two conditions agree — this fixture
|
||||
// separates them so the flag's own check is provably load-bearing rather
|
||||
// than carried by its neighbour.
|
||||
vi.mocked(loadShareViewData).mockResolvedValue({
|
||||
report: report(),
|
||||
selection: selectionFromLeaves(["WEIGHT"]),
|
||||
documents: [],
|
||||
documentOnly: true,
|
||||
} as never);
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses a link that produced no report", async () => {
|
||||
vi.mocked(loadShareViewData).mockResolvedValue({
|
||||
report: null,
|
||||
selection: selectionFromLeaves([]),
|
||||
documents: [],
|
||||
documentOnly: false,
|
||||
} as never);
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses over the rate limit, before any aggregation", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
count: 21,
|
||||
resetAt: new Date(),
|
||||
} as never);
|
||||
const { GET } = await import("../fhir/route");
|
||||
const res = await GET(req(), params);
|
||||
expect(res.status).toBe(429);
|
||||
expect(loadShareViewData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses its own rate bucket, not the document route's", async () => {
|
||||
const { GET } = await import("../fhir/route");
|
||||
await GET(req(), params);
|
||||
// Generating a bundle is far more expensive than serving a stored blob and
|
||||
// must not share a budget with it.
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"report-download:hash-1",
|
||||
20,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("records an audit row per download, scoped to the owner", async () => {
|
||||
const { GET } = await import("../fhir/route");
|
||||
await GET(req(), params);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"share-link.report.download",
|
||||
expect.objectContaining({
|
||||
userId: "owner-1",
|
||||
details: expect.objectContaining({
|
||||
shareLinkId: "link-1",
|
||||
format: "fhir",
|
||||
leafCount: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /c/{token}/report.pdf", () => {
|
||||
it("serves the link's frozen scope as a PDF", async () => {
|
||||
const { GET } = await import("../report.pdf/route");
|
||||
const res = await GET(
|
||||
new Request(`http://localhost/c/${TOKEN}/report.pdf`),
|
||||
params,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
expect(res.headers.get("content-disposition")).toContain("attachment");
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-");
|
||||
});
|
||||
|
||||
it("runs the same gate as the bundle beside it", async () => {
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(false);
|
||||
const { GET } = await import("../report.pdf/route");
|
||||
const res = await GET(
|
||||
new Request(`http://localhost/c/${TOKEN}/report.pdf`),
|
||||
params,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("records its own format in the audit row", async () => {
|
||||
const { GET } = await import("../report.pdf/route");
|
||||
await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"share-link.report.download",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ format: "pdf" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -159,8 +159,6 @@ beforeEach(() => {
|
||||
rangeStart: new Date(),
|
||||
rangeEnd: null,
|
||||
sectionsJson: {},
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* GET /c/{token}/fhir — the share link's record as an HL7 FHIR R4 document
|
||||
* Bundle, as a download.
|
||||
*
|
||||
* Behind the same unlock cookie the page and the document route use, serving
|
||||
* exactly the link's frozen selection. The insurance number is never carried:
|
||||
* the insurance leaf is refused at share-link creation, so there is nothing to
|
||||
* decrypt here and the identity block is built without it.
|
||||
*
|
||||
* The gate itself lives in `@/lib/clinician-share/report-download` so this
|
||||
* route and the PDF beside it cannot drift apart.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { resolveShareReportDownload } from "@/lib/clinician-share/report-download";
|
||||
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
|
||||
|
||||
type RouteParams = { params: Promise<{ token: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: Request, { params }: RouteParams) => {
|
||||
const { token } = await params;
|
||||
const resolved = await resolveShareReportDownload(request, token, "fhir");
|
||||
if (!resolved.ok) return resolved.response;
|
||||
|
||||
// No structured records ride a share bundle: allergies and family history
|
||||
// reach the owner's own export through a separate read that this surface
|
||||
// does not perform, so the bundle carries exactly the aggregated payload
|
||||
// the page renders and nothing beside it.
|
||||
const bundle = buildFhirDocumentBundle(resolved.report, {
|
||||
insuranceNumber: null,
|
||||
});
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(JSON.stringify(bundle), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/fhir+json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-fhir-${stamp}.json"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
+13
-25
@@ -16,7 +16,7 @@
|
||||
* string renders as escaped React text children (XSS posture).
|
||||
*/
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
@@ -29,8 +29,7 @@ import {
|
||||
verifyUnlockValue,
|
||||
} from "@/lib/clinician-share/unlock-cookie";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { ClinicianView } from "@/components/clinician/clinician-view";
|
||||
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
|
||||
@@ -45,20 +44,6 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
async function resolveLocale(): Promise<Locale> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ClinicianSharePage({
|
||||
params,
|
||||
}: {
|
||||
@@ -81,7 +66,7 @@ export default async function ClinicianSharePage({
|
||||
unlockCookieName(gate.tokenHash),
|
||||
)?.value;
|
||||
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
|
||||
const locale = await resolveLocale();
|
||||
const locale = await resolveShareViewLocale();
|
||||
const { t } = getServerTranslator(locale);
|
||||
// Resolve the gate copy on the server (it owns the locale) into a plain,
|
||||
// serializable object. A `t` function cannot cross the RSC boundary into
|
||||
@@ -107,12 +92,15 @@ export default async function ClinicianSharePage({
|
||||
// Issue #490 — period/expiry dates render in the share OWNER's (patient's)
|
||||
// profile timezone so they agree with the patient-tz aggregation behind the
|
||||
// stats and with the doctor-report PDF (never the container's zone).
|
||||
const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] =
|
||||
await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveLocale(),
|
||||
resolveUserTimezone(context.ownerUserId),
|
||||
]);
|
||||
const [
|
||||
{ report, selection, documents, documentOnly },
|
||||
locale,
|
||||
ownerTimezone,
|
||||
] = await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveShareViewLocale(),
|
||||
resolveUserTimezone(context.ownerUserId),
|
||||
]);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
return (
|
||||
@@ -121,7 +109,7 @@ export default async function ClinicianSharePage({
|
||||
label={context.label}
|
||||
expiresAt={context.expiresAt.toISOString()}
|
||||
report={report}
|
||||
sections={sections}
|
||||
selection={selection}
|
||||
documents={documents}
|
||||
documentOnly={documentOnly}
|
||||
token={token}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* GET /c/{token}/report.pdf — the share link's record as the clinical PDF.
|
||||
*
|
||||
* Behind the same unlock cookie the page and the document route use, serving
|
||||
* exactly the link's frozen selection. Charts are embedded, matching the
|
||||
* owner's own default; the practice-name cover line is whatever the owner
|
||||
* froze onto the report, which for a share link is nothing.
|
||||
*
|
||||
* The gate itself lives in `@/lib/clinician-share/report-download` so this
|
||||
* route and the bundle beside it cannot drift apart.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { resolveShareReportDownload } from "@/lib/clinician-share/report-download";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale";
|
||||
|
||||
type RouteParams = { params: Promise<{ token: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: Request, { params }: RouteParams) => {
|
||||
const { token } = await params;
|
||||
const resolved = await resolveShareReportDownload(request, token, "pdf");
|
||||
if (!resolved.ok) return resolved.response;
|
||||
|
||||
// The recipient's locale, not the owner's: this document is being read by
|
||||
// the practice, and the page above it already renders in the same one.
|
||||
const locale = await resolveShareViewLocale();
|
||||
const { t } = getServerTranslator(locale);
|
||||
const bytes = renderDoctorReportPdfBytes(resolved.report, {
|
||||
t,
|
||||
locale,
|
||||
insuranceNumber: null,
|
||||
includeCharts: true,
|
||||
});
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return new NextResponse(bytes.slice().buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="healthlog-report-${stamp}.pdf"`,
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -50,22 +50,32 @@ export default function InsightsGewichtPage() {
|
||||
const weightTransform = unitDisplay.transformFor("WEIGHT");
|
||||
const weightUnit = weightTransform.displayUnit;
|
||||
const weightScale = weightTransform.factor;
|
||||
// v1.32.39 — the strip + band values convert through `toDisplay`, which
|
||||
// rounds to the transform's own decimals. `weightScale` stays for the chart
|
||||
// alone: it folds the factor into its series and formats at its own
|
||||
// precision, so it needs the raw multiplier rather than a rounded value.
|
||||
const toDisplayWeight = (value: number) =>
|
||||
unitDisplay.toDisplay("WEIGHT", value);
|
||||
const displaySummary =
|
||||
weightSummary && weightScale !== 1
|
||||
? {
|
||||
...weightSummary,
|
||||
min:
|
||||
weightSummary.min === null ? null : weightSummary.min * weightScale,
|
||||
weightSummary.min === null
|
||||
? null
|
||||
: toDisplayWeight(weightSummary.min),
|
||||
max:
|
||||
weightSummary.max === null ? null : weightSummary.max * weightScale,
|
||||
weightSummary.max === null
|
||||
? null
|
||||
: toDisplayWeight(weightSummary.max),
|
||||
mean:
|
||||
weightSummary.mean === null
|
||||
? null
|
||||
: weightSummary.mean * weightScale,
|
||||
: toDisplayWeight(weightSummary.mean),
|
||||
median:
|
||||
weightSummary.median === null
|
||||
? null
|
||||
: weightSummary.median * weightScale,
|
||||
: toDisplayWeight(weightSummary.median),
|
||||
}
|
||||
: weightSummary;
|
||||
|
||||
@@ -108,8 +118,8 @@ export default function InsightsGewichtPage() {
|
||||
weightBands && weightScale !== 1
|
||||
? weightBands.map((band) => ({
|
||||
...band,
|
||||
min: band.min * weightScale,
|
||||
max: band.max * weightScale,
|
||||
min: toDisplayWeight(band.min),
|
||||
max: toDisplayWeight(band.max),
|
||||
}))
|
||||
: weightBands;
|
||||
|
||||
@@ -122,6 +132,7 @@ export default function InsightsGewichtPage() {
|
||||
<MetricStatStrip
|
||||
summary={displaySummary}
|
||||
unit={weightUnit}
|
||||
fractionDigits={weightTransform.decimals}
|
||||
seriesLabel={t("insights.weightSectionTitle")}
|
||||
icon={Scale}
|
||||
windowStats={statsByType?.WEIGHT ?? null}
|
||||
|
||||
+22
-8
@@ -1,6 +1,7 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
@@ -102,18 +103,31 @@ export default async function RootLayout({
|
||||
scripts/generate-i18n-boot.mjs, a prebuild/predev step).
|
||||
Replaces the former RSC-prop handoff that inlined the whole
|
||||
active catalog into every document's flight payload (392 KB of a
|
||||
505 KB dashboard HTML). `defer` keeps it off the parse/paint
|
||||
critical path while still executing before Next's hydration
|
||||
module scripts (both ride the after-parse in-order queue, and
|
||||
this tag comes first), so `load-locale.ts` finds `self.__HL_I18N`
|
||||
set when the client bundle initializes — SSR text and first
|
||||
client render stay identical. Served as a static asset: ETag
|
||||
505 KB dashboard HTML). Served as a static asset: ETag
|
||||
revalidation (304 on repeat loads) + SW cache-first on the
|
||||
versioned URL replace the per-document payload.
|
||||
|
||||
`beforeInteractive` is load-bearing, not a preference. The
|
||||
catalog has to be in hand when the client bundle takes its FIRST
|
||||
render, or `t()` resolves raw keys against an empty bundle while
|
||||
the server HTML carries real text — React tears the tree down
|
||||
with hydration error #418 and the user watches a frame of
|
||||
`nav.skipToContent` before the provider's backfill lands. A plain
|
||||
`<script defer>` cannot promise that: Next emits its own chunks
|
||||
as `async` tags AHEAD of this element in `<head>`, and an `async`
|
||||
script runs the moment its fetch settles, so the 370 KB catalog
|
||||
was simply racing the app bundle — and lost about a third of the
|
||||
time on a cold load. `beforeInteractive` hands the URL to Next's
|
||||
own bootstrap, which awaits it before requiring a single app
|
||||
module (`next/dist/client/app-bootstrap.js`), and pairs it with a
|
||||
`<link rel=preload>` so the download still starts with the
|
||||
document rather than after it.
|
||||
*/}
|
||||
<script
|
||||
<Script
|
||||
id="healthlog-i18n-boot"
|
||||
strategy="beforeInteractive"
|
||||
nonce={nonce}
|
||||
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
|
||||
defer
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
/**
|
||||
* v1.11.0 — clinician-view presentation (Epic C, C5).
|
||||
* The public clinician view.
|
||||
*
|
||||
* A pure server component: it receives an already-resolved, owner-scoped
|
||||
* {@link DoctorReportData} plus a server-side translator and renders a
|
||||
* read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO
|
||||
* read-only clinical summary. NO client hooks, NO session, NO AI or coach, NO
|
||||
* markdown — every value renders as escaped React text.
|
||||
*
|
||||
* Layout: provenance header → clinical vitals/labs → medications + adherence →
|
||||
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
|
||||
* clinical assessment / not a diagnosis" disclaimer.
|
||||
* Layout: provenance header (with the two machine-format downloads) → the
|
||||
* measurement groups in selection order → glucose → medications and adherence
|
||||
* → a FENCED, muted wellness card carrying the load-bearing "descriptive, not
|
||||
* a clinical assessment" disclaimer → the attached documents.
|
||||
*
|
||||
* The section components live in `./report-sections`, the document list in
|
||||
* `./documents-list`, the downloads in `./download-actions`.
|
||||
*/
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { formatBytes } from "@/components/documents/vault-utils";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
import { DocumentEntry } from "./documents-list";
|
||||
import { ShareDownloadActions } from "./download-actions";
|
||||
import {
|
||||
GlucoseSection,
|
||||
MeasurementGroups,
|
||||
MedicationsSection,
|
||||
Section,
|
||||
StatRow,
|
||||
WellnessSection,
|
||||
} from "./report-sections";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
@@ -34,22 +42,22 @@ interface ClinicianViewProps {
|
||||
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
|
||||
expiresAt: string;
|
||||
/**
|
||||
* The owner-scoped report payload, or `null` for a documents-only share
|
||||
* (no report section enabled). When `null` NO health metric is rendered —
|
||||
* only the header, disclaimer, and the attached documents.
|
||||
* The owner-scoped report payload, or `null` for a documents-only share.
|
||||
* When `null` NO health metric is rendered — only the header, the
|
||||
* disclaimer, and the attached documents.
|
||||
*/
|
||||
report: DoctorReportData | null;
|
||||
sections: DoctorReportPrefs;
|
||||
/** The link's frozen selection, resolved. */
|
||||
selection: ReportSelection;
|
||||
/**
|
||||
* v1.28.13 — a documents-only link. Hides the reporting-period line (there is
|
||||
* no report) and, together with a `null` report, keeps every health section
|
||||
* off the page. "Share this document" means the document, not the record.
|
||||
* A documents-only link. Hides the reporting-period line (there is no
|
||||
* report) and, together with a `null` report, keeps every health section off
|
||||
* the page.
|
||||
*/
|
||||
documentOnly?: boolean;
|
||||
/**
|
||||
* The frozen document set on this link (metadata only — never bytes). Each
|
||||
* entry points at the token-scoped serve route (`/c/<token>/d/<id>`), the
|
||||
* one decrypt path. Empty when the owner shared no documents.
|
||||
* entry points at the token-scoped serve route, the one decrypt path.
|
||||
*/
|
||||
documents?: ShareViewDocument[];
|
||||
/** The raw share token from the path — used to build serve-route URLs. */
|
||||
@@ -57,154 +65,19 @@ interface ClinicianViewProps {
|
||||
/** Viewer locale (byte formatting only). Defaults to English. */
|
||||
locale?: Locale;
|
||||
/**
|
||||
* Issue #490 — the share OWNER's (patient's) profile timezone. Period
|
||||
* start/end and the expiry date render in this zone so the dates agree
|
||||
* with the patient-tz aggregation behind the stats (and with the
|
||||
* doctor-report PDF). Pre-fix the dates rendered via a bare
|
||||
* `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
|
||||
* the patient's nor the viewer's, with US-English field order regardless
|
||||
* of the resolved page locale.
|
||||
* Issue #490 — the share OWNER's profile timezone. Period start/end and the
|
||||
* expiry date render in this zone so the dates agree with the patient-tz
|
||||
* aggregation behind the stats (and with the doctor-report PDF).
|
||||
*/
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
|
||||
const WELLNESS_KEY: Record<string, string> = {
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "stress",
|
||||
STRAIN_SCORE: "strain",
|
||||
};
|
||||
|
||||
/**
|
||||
* Localised label for a measurement-type enum, reusing the doctor-report type
|
||||
* key map so the clinician view reads in the viewer's locale alongside the rest
|
||||
* of the page. A type with no key falls back to a humanised enum form
|
||||
* (`BLOOD_PRESSURE_SYS` → "Blood pressure sys").
|
||||
*/
|
||||
function typeLabel(type: string, t: Translate): string {
|
||||
const key = DOCTOR_REPORT_TYPE_LABEL_KEYS[type];
|
||||
if (key) return t(key);
|
||||
const lower = type.replace(/_/g, " ").toLowerCase();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
|
||||
/** Render a single labelled stat row. */
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border-border/40 flex items-baseline justify-between gap-4 border-b py-2 last:border-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
|
||||
<h2 className="mb-3 text-base font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared document. Class A (inline) renders a preview straight at the
|
||||
* token-scoped serve route — an `<img>` for raster images, a framed PDF
|
||||
* otherwise; the proxy's share-serve CSP (`default-src 'none';
|
||||
* frame-ancestors 'self'`) fences the framed context. Class B (attachment)
|
||||
* offers a plain download link — the serve route answers it as an opaque
|
||||
* `application/octet-stream` attachment. No bytes reach this component; only
|
||||
* the metadata and the URL.
|
||||
*/
|
||||
function DocumentEntry({
|
||||
t,
|
||||
doc,
|
||||
token,
|
||||
locale,
|
||||
}: {
|
||||
t: Translate;
|
||||
doc: ShareViewDocument;
|
||||
token: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
|
||||
const title = doc.title ?? t("clinicianView.documents.untitled");
|
||||
const Icon =
|
||||
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
|
||||
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
const isImage = doc.mimeType.startsWith("image/");
|
||||
const isInline = doc.servingClass === "inline";
|
||||
|
||||
return (
|
||||
<li className="border-border bg-card rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
{Icon ? (
|
||||
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium break-words">{title}</p>
|
||||
{meta.length > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{meta.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Download is always available (inline or attachment) so the recipient
|
||||
can keep a copy; the anchor hits the same token-scoped serve route. */}
|
||||
<a
|
||||
href={serveUrl}
|
||||
download
|
||||
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
{t("clinicianView.documents.download")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isInline ? (
|
||||
<div className="mt-3">
|
||||
{isImage ? (
|
||||
// The served image is the frozen original with EXIF/GPS stripped on
|
||||
// egress; it renders inline within the Class A carve-out.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={title}
|
||||
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={serveUrl}
|
||||
title={title}
|
||||
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("clinicianView.documents.attachmentHint")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinicianView({
|
||||
t,
|
||||
label,
|
||||
expiresAt,
|
||||
report,
|
||||
sections,
|
||||
selection,
|
||||
documents = [],
|
||||
documentOnly = false,
|
||||
token = "",
|
||||
@@ -217,22 +90,6 @@ export function ClinicianView({
|
||||
const fmtDate = (iso: string) => fmt.date(new Date(iso));
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
// A documents-only share carries no report — the aggregator is never called
|
||||
// server-side, so `report` is null. Every health-derived list below stays
|
||||
// empty by construction and only the attached documents render.
|
||||
const measurementEntries = report
|
||||
? Object.entries(report.stats).filter(([, s]) => s.count > 0)
|
||||
: [];
|
||||
const glucoseEntries = report
|
||||
? Object.entries(report.glucoseStats).filter(([, s]) => s.count > 0)
|
||||
: [];
|
||||
const complianceEntries =
|
||||
report && sections.compliance
|
||||
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
|
||||
: [];
|
||||
const wellness = report?.wellnessScores?.filter((s) => s.count > 0) ?? [];
|
||||
const medications = report?.medications ?? [];
|
||||
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
@@ -257,118 +114,30 @@ export function ClinicianView({
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
|
||||
</p>
|
||||
{report && !documentOnly && token ? (
|
||||
<ShareDownloadActions t={t} token={token} />
|
||||
) : null}
|
||||
<p className="border-border bg-muted/40 text-muted-foreground mt-3 rounded-md border p-3 text-xs">
|
||||
{t("clinicianView.provenance")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* ── Clinical vitals ─────────────────────────────────────── */}
|
||||
{measurementEntries.length > 0 ? (
|
||||
<Section title={t("clinicianView.vitals")}>
|
||||
{measurementEntries.map(([type, s]) => (
|
||||
<StatRow
|
||||
key={type}
|
||||
label={typeLabel(type, t)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{report &&
|
||||
report.bmi !== null &&
|
||||
report.bmi !== undefined &&
|
||||
sections.bmi ? (
|
||||
<StatRow
|
||||
label={t("clinicianView.bmi")}
|
||||
value={String(fmtNum(report.bmi))}
|
||||
/>
|
||||
{report ? (
|
||||
<>
|
||||
<MeasurementGroups t={t} report={report} fmtNum={fmtNum} />
|
||||
{report.bmi !== null && report.bmi !== undefined ? (
|
||||
<Section title={t("clinicianView.bmiSection")}>
|
||||
<StatRow
|
||||
label={t("clinicianView.bmi")}
|
||||
value={String(fmtNum(report.bmi))}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Labs (glucose) ──────────────────────────────────────── */}
|
||||
{glucoseEntries.length > 0 ? (
|
||||
<Section title={t("clinicianView.labs")}>
|
||||
{glucoseEntries.map(([ctx, s]) => (
|
||||
<StatRow
|
||||
key={ctx}
|
||||
label={t(`clinicianView.glucose.${ctx}`)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Medications + adherence ─────────────────────────────── */}
|
||||
{medications.length > 0 ? (
|
||||
<Section title={t("clinicianView.medications")}>
|
||||
{medications.map((med) => {
|
||||
const comp = report?.compliance[med.name];
|
||||
const rate =
|
||||
sections.compliance && comp && comp.total > 0
|
||||
? `${Math.round((comp.taken / comp.total) * 100)}%`
|
||||
: null;
|
||||
return (
|
||||
<StatRow
|
||||
key={med.name}
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{complianceEntries
|
||||
.filter(([name]) => !medications.some((m) => m.name === name))
|
||||
.map(([name, c]) => (
|
||||
<StatRow
|
||||
key={name}
|
||||
label={name}
|
||||
value={t("clinicianView.adherence", {
|
||||
rate: `${Math.round((c.taken / c.total) * 100)}%`,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
|
||||
{wellness.length > 0 ? (
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
|
||||
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
|
||||
{t("clinicianView.wellness.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
{t("clinicianView.wellness.disclaimer")}
|
||||
</p>
|
||||
<div>
|
||||
{wellness.map((s) => (
|
||||
<StatRow
|
||||
key={s.type}
|
||||
label={t(
|
||||
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
|
||||
)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<GlucoseSection t={t} report={report} fmtNum={fmtNum} />
|
||||
<MedicationsSection t={t} report={report} selection={selection} />
|
||||
<WellnessSection t={t} report={report} fmtNum={fmtNum} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── Shared documents ────────────────────────────────────── */}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The frozen document set on a share link, as a list of entries.
|
||||
*
|
||||
* No bytes reach this component: each entry points at the token-scoped serve
|
||||
* route, which is the one decrypt path. Class A (inline) renders a preview
|
||||
* straight at that route — an `<img>` for raster images, a framed PDF
|
||||
* otherwise, fenced by the proxy's share-serve CSP. Class B is download-only.
|
||||
*
|
||||
* Split out of `clinician-view.tsx` with the selection rework.
|
||||
*/
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { formatBytes } from "@/components/documents/vault-utils";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function DocumentEntry({
|
||||
t,
|
||||
doc,
|
||||
token,
|
||||
locale,
|
||||
}: {
|
||||
t: Translate;
|
||||
doc: ShareViewDocument;
|
||||
token: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
|
||||
const title = doc.title ?? t("clinicianView.documents.untitled");
|
||||
const Icon =
|
||||
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
|
||||
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
const isImage = doc.mimeType.startsWith("image/");
|
||||
const isInline = doc.servingClass === "inline";
|
||||
|
||||
return (
|
||||
<li className="border-border bg-card rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
{Icon ? (
|
||||
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium break-words">{title}</p>
|
||||
{meta.length > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{meta.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Download is always available (inline or attachment) so the recipient
|
||||
can keep a copy; the anchor hits the same token-scoped serve route. */}
|
||||
<a
|
||||
href={serveUrl}
|
||||
download
|
||||
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
{t("clinicianView.documents.download")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isInline ? (
|
||||
<div className="mt-3">
|
||||
{isImage ? (
|
||||
// The served image is the frozen original with EXIF/GPS stripped on
|
||||
// egress; it renders inline within the Class A carve-out.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={title}
|
||||
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={serveUrl}
|
||||
title={title}
|
||||
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("clinicianView.documents.attachmentHint")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The two machine-format downloads on a share link.
|
||||
*
|
||||
* A practice opens the link, unlocks it, and saves the file into their system.
|
||||
* These serve exactly the link's frozen selection — the same object the page
|
||||
* above renders from — behind the same unlock cookie the document route
|
||||
* enforces. There is no bearer face and no new scope.
|
||||
*
|
||||
* Rendered only for a link that actually carries a record; a documents-only
|
||||
* share has nothing to download here.
|
||||
*/
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function ShareDownloadActions({
|
||||
t,
|
||||
token,
|
||||
}: {
|
||||
t: Translate;
|
||||
token: string;
|
||||
}) {
|
||||
const base = `/c/${encodeURIComponent(token)}`;
|
||||
const actions = [
|
||||
{ href: `${base}/report.pdf`, label: t("clinicianView.downloadPdf") },
|
||||
{ href: `${base}/fhir`, label: t("clinicianView.downloadFhir") },
|
||||
];
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2" data-testid="share-downloads">
|
||||
{actions.map((action) => (
|
||||
<a
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
download
|
||||
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
||||
>
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
{action.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* The health sections of the clinician view, grouped the way the owner chose
|
||||
* them.
|
||||
*
|
||||
* The measurement list used to be a flat `Object.entries(report.stats)` with a
|
||||
* humanised-enum fallback for any type the ten-entry label map missed, so a
|
||||
* clinician read rows called "Phq9 score" and "Audio exposure headphone" in
|
||||
* one undifferentiated column. It now renders in the same twelve groups the
|
||||
* selection panel shows, with the real localised labels, so the page and the
|
||||
* PDF describe the record in the same order.
|
||||
*
|
||||
* A pure server component: no client hooks, no session, no markdown — every
|
||||
* value renders as escaped React text.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import {
|
||||
isStructuredLeafId,
|
||||
REPORT_GROUPS,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
|
||||
const WELLNESS_KEY: Record<string, string> = {
|
||||
RECOVERY_SCORE: "recovery",
|
||||
STRESS_SCORE: "stress",
|
||||
STRAIN_SCORE: "strain",
|
||||
};
|
||||
|
||||
/** Render a single labelled stat row. */
|
||||
export function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border-border/40 flex items-baseline justify-between gap-4 border-b py-2 last:border-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
|
||||
<h2 className="mb-3 text-base font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The measurement groups that carry data, in selection order. The aggregator
|
||||
* has already applied the selection, so a type missing from `report.stats` is
|
||||
* one that was withheld or has no reading.
|
||||
*/
|
||||
export function MeasurementGroups({
|
||||
t,
|
||||
report,
|
||||
fmtNum,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
fmtNum: (n: number) => number;
|
||||
}) {
|
||||
const groups = REPORT_GROUPS.map((group) => ({
|
||||
labelKey: group.labelKey,
|
||||
rows: group.leaves
|
||||
.filter((leaf): leaf is MeasurementType => !isStructuredLeafId(leaf))
|
||||
.map((type) => ({ type, stat: report.stats[type] }))
|
||||
.filter((row) => row.stat !== undefined && row.stat.count > 0),
|
||||
})).filter((group) => group.rows.length > 0);
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<Section key={group.labelKey} title={t(group.labelKey)}>
|
||||
{group.rows.map(({ type, stat }) => (
|
||||
<StatRow
|
||||
key={type}
|
||||
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(stat!.latest),
|
||||
avg: fmtNum(stat!.avg),
|
||||
min: fmtNum(stat!.min),
|
||||
max: fmtNum(stat!.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-context glucose stats. Present only when the glucose panel was chosen. */
|
||||
export function GlucoseSection({
|
||||
t,
|
||||
report,
|
||||
fmtNum,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
fmtNum: (n: number) => number;
|
||||
}) {
|
||||
const entries = Object.entries(report.glucoseStats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<Section title={t("clinicianView.labs")}>
|
||||
{entries.map(([ctx, s]) => (
|
||||
<StatRow
|
||||
key={ctx}
|
||||
label={t(`clinicianView.glucose.${ctx}`)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The medication list with the adherence rate beside each drug. */
|
||||
export function MedicationsSection({
|
||||
t,
|
||||
report,
|
||||
selection,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
selection: ReportSelection;
|
||||
}) {
|
||||
const medications = report.medications ?? [];
|
||||
const complianceOn = selection.has("MEDICATION_COMPLIANCE");
|
||||
const complianceEntries = complianceOn
|
||||
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
|
||||
: [];
|
||||
if (medications.length === 0 && complianceEntries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Section title={t("clinicianView.medications")}>
|
||||
{medications.map((med) => {
|
||||
const comp = report.compliance[med.name];
|
||||
const rate =
|
||||
complianceOn && comp && comp.total > 0
|
||||
? `${Math.round((comp.taken / comp.total) * 100)}%`
|
||||
: null;
|
||||
return (
|
||||
<StatRow
|
||||
key={med.name}
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{complianceEntries
|
||||
.filter(([name]) => !medications.some((m) => m.name === name))
|
||||
.map(([name, c]) => (
|
||||
<StatRow
|
||||
key={name}
|
||||
label={name}
|
||||
value={t("clinicianView.adherence", {
|
||||
rate: `${Math.round((c.taken / c.total) * 100)}%`,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fenced wellness card. Descriptive composites, not clinical assessments —
|
||||
* the disclaimer is load-bearing and sits above the numbers, not under them.
|
||||
*/
|
||||
export function WellnessSection({
|
||||
t,
|
||||
report,
|
||||
fmtNum,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
fmtNum: (n: number) => number;
|
||||
}) {
|
||||
const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
|
||||
if (wellness.length === 0) return null;
|
||||
return (
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
|
||||
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
|
||||
{t("clinicianView.wellness.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
{t("clinicianView.wellness.disclaimer")}
|
||||
</p>
|
||||
<div>
|
||||
{wellness.map((s) => (
|
||||
<StatRow
|
||||
key={s.type}
|
||||
label={t(
|
||||
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
|
||||
)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -288,26 +288,24 @@ export function HealthKitMetricPage({
|
||||
// min / max / median / mean so the numbers and the unit agree. All four are
|
||||
// ABSOLUTE values, so they take the offset; an untransformed metric keeps
|
||||
// scale 1 / offset 0 → byte-identical.
|
||||
// v1.32.39 — a transformed type converts through `toDisplay`, which rounds
|
||||
// to the transform's own decimals. Hand-rolling `value * scale + offset`
|
||||
// here is what let a converted stat reach the strip unrounded; the raw
|
||||
// scale below is now only ever handed to the chart, which folds it into its
|
||||
// own series and formats at its own precision.
|
||||
const toStripValue = (value: number) =>
|
||||
transform
|
||||
? unitDisplay.toDisplay(effectiveType, value)
|
||||
: value * resolvedScale + resolvedOffset;
|
||||
const summary =
|
||||
rawSummary && (resolvedScale !== 1 || resolvedOffset !== 0)
|
||||
? {
|
||||
...rawSummary,
|
||||
min:
|
||||
rawSummary.min === null
|
||||
? null
|
||||
: rawSummary.min * resolvedScale + resolvedOffset,
|
||||
max:
|
||||
rawSummary.max === null
|
||||
? null
|
||||
: rawSummary.max * resolvedScale + resolvedOffset,
|
||||
mean:
|
||||
rawSummary.mean === null
|
||||
? null
|
||||
: rawSummary.mean * resolvedScale + resolvedOffset,
|
||||
min: rawSummary.min === null ? null : toStripValue(rawSummary.min),
|
||||
max: rawSummary.max === null ? null : toStripValue(rawSummary.max),
|
||||
mean: rawSummary.mean === null ? null : toStripValue(rawSummary.mean),
|
||||
median:
|
||||
rawSummary.median === null
|
||||
? null
|
||||
: rawSummary.median * resolvedScale + resolvedOffset,
|
||||
rawSummary.median === null ? null : toStripValue(rawSummary.median),
|
||||
}
|
||||
: rawSummary;
|
||||
|
||||
|
||||
@@ -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,277 @@
|
||||
/**
|
||||
* 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. A first run must select
|
||||
* NOTHING — no template, no server default, no group half on — and must leave
|
||||
* Generate disabled until a person ticks something, with the standard-report
|
||||
* button one click away. A repeat run must open collapsed carrying the owner's
|
||||
* own last scope. A set that arrives without being asked for is a
|
||||
* preselection whatever it is called, and material nobody ticked is exactly
|
||||
* what this surface must never send.
|
||||
*
|
||||
* SSR-only, per the project convention for this surface.
|
||||
*
|
||||
* Mutation checks (each verified red, then reverted):
|
||||
* - remove the seeding block → "pre-fills the input" and "restores the saved
|
||||
* selection" go red.
|
||||
* - seed `selected` from `STANDARD_TEMPLATE_LEAVES` → "selects nothing on a
|
||||
* first run", "counts nothing in any group" and "disables Generate" go
|
||||
* red.
|
||||
* - start `pickerOpen` at `false` → "opens expanded on the first run" goes
|
||||
* red.
|
||||
* - drop `selected.size === 0` from the Generate `disabled` expression →
|
||||
* "disables Generate while the scope is empty" 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}-export"[\\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}-export"[\\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] ?? "";
|
||||
}
|
||||
|
||||
/** Whether the Generate control refuses the press. */
|
||||
function generateDisabled(html: string): boolean {
|
||||
const button = html.match(
|
||||
/<button[^>]*data-testid="health-record-generate"[^>]*>/,
|
||||
)?.[0];
|
||||
if (!button) throw new Error("the Generate button did not render");
|
||||
return /\sdisabled=""/.test(button);
|
||||
}
|
||||
|
||||
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 with the standard-report button in view", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(html).toContain('data-testid="health-record-included-data-panel"');
|
||||
expect(html).toContain('data-testid="report-scope-picker-export"');
|
||||
// The template is offered by name on a button, not applied behind the
|
||||
// user's back. One click is still one click; it just has to happen.
|
||||
expect(html).toContain('data-testid="report-apply-standard-export"');
|
||||
expect(html).toContain("Apply Standard doctor's report");
|
||||
});
|
||||
|
||||
it("selects nothing at all — no template, no default", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(html).toContain('data-testid="report-scope-summary-export"');
|
||||
expect(html).toContain("Nothing selected yet");
|
||||
// Every leaf the panel renders at all is off. Only the fenced tier renders
|
||||
// leaf rows while every group is collapsed, and none of them is checked.
|
||||
for (const leaf of ["MOOD", "CYCLE", "FAMILY_HISTORY", "PHQ9_SCORE"]) {
|
||||
expect(leafChecked(html, leaf), leaf).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts nothing in any group, the template's own groups included", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
// These six carry the standard template's leaves. On a first run they read
|
||||
// 0/n like every other group: the template is a button, not a starting
|
||||
// state.
|
||||
expect(groupChip(html, "identity")).toBe("0/2");
|
||||
expect(groupChip(html, "vitals")).toBe("0/7");
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
expect(groupChip(html, "history")).toBe("0/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("disables Generate while the scope is empty, and says what to do", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(generateDisabled(html)).toBe(true);
|
||||
// The reason sits beside the control and points at the way out of it,
|
||||
// rather than telling the person off after they press.
|
||||
expect(html).toContain(
|
||||
"Nothing selected yet. Choose what to include, or apply the standard report.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves every fenced leaf unchecked and renders the tier", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(html).toContain('data-testid="report-sensitive-tier-export"');
|
||||
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-export"');
|
||||
expect(html).not.toContain(
|
||||
'data-testid="report-group-check-sensitive-export"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("enables Generate once a scope has been chosen", () => {
|
||||
const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"]));
|
||||
expect(generateDisabled(html)).toBe(false);
|
||||
expect(html).not.toContain("Nothing selected yet");
|
||||
});
|
||||
|
||||
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 an empty scope 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
|
||||
// treats it as a first run: nothing selected, Generate refused, the
|
||||
// standard-report button one click away.
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(generateDisabled(html)).toBe(true);
|
||||
expect(html).toContain('data-testid="report-apply-standard-export"');
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,14 @@
|
||||
* SSR-static render only (no jsdom), matching the sibling settings tests. The
|
||||
* TanStack hooks are mocked; the QR (`qrcode`) only ever renders in an effect
|
||||
* after a create, so it never runs under `renderToStaticMarkup`.
|
||||
*
|
||||
* Mutation checks (each verified red, then reverted):
|
||||
* - seed `selectedLeaves` from `STANDARD_TEMPLATE_LEAVES` again → "starts a
|
||||
* record link with an empty scope" goes red.
|
||||
* - drop `selectedLeaves.size === 0` from the submit `disabled` expression →
|
||||
* "refuses to mint a record link with an empty scope" goes red.
|
||||
*/
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -46,6 +53,15 @@ function render(
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the create control refuses the press. */
|
||||
function createDisabled(html: string): boolean {
|
||||
const button = html.match(
|
||||
/<button[^>]*data-testid="share-create-submit"[^>]*>/,
|
||||
)?.[0];
|
||||
if (!button) throw new Error("the create button did not render");
|
||||
return /\sdisabled=""/.test(button);
|
||||
}
|
||||
|
||||
describe("<ShareLinkCreateForm> — shared create flow", () => {
|
||||
it("renders the create form with the 90-day-capped expiry and attach affordance", () => {
|
||||
const html = render();
|
||||
@@ -86,6 +102,28 @@ describe("<ShareLinkCreateForm> — shared create flow", () => {
|
||||
expect(html).toContain("Camera metadata");
|
||||
});
|
||||
|
||||
it("starts a record link with an empty scope and the standard-report button", () => {
|
||||
const html = render();
|
||||
// A link is handed to another person; nothing rides on it that was not
|
||||
// ticked. The template is reachable in one click and applied in none.
|
||||
expect(html).toContain("Nothing selected yet");
|
||||
expect(html).toContain('data-testid="report-apply-standard-share"');
|
||||
expect(html).toContain("Apply Standard doctor's report");
|
||||
});
|
||||
|
||||
it("refuses to mint a record link with an empty scope, label or not", () => {
|
||||
const html = render({ initialLabel: "Cardiology" });
|
||||
expect(createDisabled(html)).toBe(true);
|
||||
// The same form in documents-only mode mints happily on the same label:
|
||||
// the refusal comes from the empty report scope, not from the label.
|
||||
const docOnly = render({
|
||||
documentOnly: true,
|
||||
initialLabel: "Cardiology",
|
||||
initialDocuments: [{ id: "doc-1", title: "Blood panel 2026" }],
|
||||
});
|
||||
expect(createDisabled(docOnly)).toBe(false);
|
||||
});
|
||||
|
||||
it("document-only mode hides every record-scope control but keeps expiry + the doc", () => {
|
||||
const html = render({
|
||||
documentOnly: true,
|
||||
@@ -110,37 +148,43 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
|
||||
label: " Blood panel 2026 ",
|
||||
rangeDays: 30,
|
||||
expiryDays: 30,
|
||||
leaves: ["WEIGHT", "LAB_RESULTS"] as ReportLeafId[],
|
||||
};
|
||||
|
||||
it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => {
|
||||
it("document-only: empty report scope and the launched doc always rides along", () => {
|
||||
const payload = buildShareLinkCreatePayload({
|
||||
...common,
|
||||
// Even if the caller state still holds record-scope values, they must be
|
||||
// dropped: a document link never carries a health scope.
|
||||
allowFhirApi: true,
|
||||
resourceTypes: ["Patient", "Observation"],
|
||||
documentIds: ["doc-1"],
|
||||
documentOnly: true,
|
||||
});
|
||||
expect(payload.resourceTypes).toEqual([]);
|
||||
expect(payload.allowFhirApi).toBe(false);
|
||||
expect(payload.documentOnly).toBe(true);
|
||||
// No report sections are ever sent for a document link.
|
||||
// The retired FHIR scope keys are gone from the wire entirely,
|
||||
// so a share link can no longer advertise a face that was never served.
|
||||
expect(payload).not.toHaveProperty("resourceTypes");
|
||||
expect(payload).not.toHaveProperty("allowFhirApi");
|
||||
// No report 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"]);
|
||||
expect(payload.label).toBe("Blood panel 2026");
|
||||
});
|
||||
|
||||
it("record share: keeps the full scope and omits documentIds when none picked", () => {
|
||||
it("record share: keeps the window scope and omits documentIds when none picked", () => {
|
||||
const payload = buildShareLinkCreatePayload({
|
||||
...common,
|
||||
allowFhirApi: false,
|
||||
resourceTypes: ["Patient", "Observation"],
|
||||
documentIds: [],
|
||||
documentOnly: false,
|
||||
});
|
||||
expect(payload.resourceTypes).toEqual(["Patient", "Observation"]);
|
||||
expect(payload.rangeEnd).toBeNull();
|
||||
// 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");
|
||||
expect(payload).not.toHaveProperty("documentOnly");
|
||||
});
|
||||
@@ -148,8 +192,6 @@ describe("buildShareLinkCreatePayload — scope contract", () => {
|
||||
it("record share: attaches the picked documents when the owner chose some", () => {
|
||||
const payload = buildShareLinkCreatePayload({
|
||||
...common,
|
||||
allowFhirApi: false,
|
||||
resourceTypes: ["Patient", "Observation"],
|
||||
documentIds: ["a", "b"],
|
||||
documentOnly: false,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The pick-again state on a link the selection upgrade closed.
|
||||
*
|
||||
* Migration 0273 revoked every live record share link minted before the
|
||||
* selection model, because their frozen scope was "everything except mood and
|
||||
* cycle" — a scope nobody chose. The owner has to see WHY the link stopped
|
||||
* working and be able to replace it without retyping what they already
|
||||
* decided, or the revocation reads as a fault.
|
||||
*
|
||||
* Mutation checks:
|
||||
* - drop the `needsReselection` branch in `sharing-section.tsx` → "explains
|
||||
* why the link closed" goes red.
|
||||
* - drop `initialRangeDays` from `ShareLinkCreateForm`'s state seed →
|
||||
* "carries the closed link's window into the replacement" goes red.
|
||||
* - clamp nothing in `prefillFrom` → "clamps a lifetime past the cap" goes
|
||||
* red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ShareLinkCreateForm } from "../share-link-create-form";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQuery: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
|
||||
function render(node: React.ReactElement): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function numberInputValue(html: string, id: string): string | null {
|
||||
const input = html.match(new RegExp(`<input[^>]*id="${id}"[^>]*>`))?.[0];
|
||||
return input?.match(/value="([^"]*)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
describe("share-link create form — re-mint seeding", () => {
|
||||
it("carries the closed link's window and lifetime into the replacement", () => {
|
||||
const html = render(
|
||||
<ShareLinkCreateForm
|
||||
initialLabel="Cardiology clinic"
|
||||
initialRangeDays={180}
|
||||
initialExpiryDays={45}
|
||||
/>,
|
||||
);
|
||||
expect(numberInputValue(html, "share-range")).toBe("180");
|
||||
expect(numberInputValue(html, "share-expiry")).toBe("45");
|
||||
expect(html).toContain("Cardiology clinic");
|
||||
});
|
||||
|
||||
it("falls back to the 30-day default when nothing is carried across", () => {
|
||||
const html = render(<ShareLinkCreateForm />);
|
||||
expect(numberInputValue(html, "share-range")).toBe("30");
|
||||
expect(numberInputValue(html, "share-expiry")).toBe("30");
|
||||
});
|
||||
|
||||
it("offers the scope picker, with the insurance leaf absent from it", () => {
|
||||
const html = render(<ShareLinkCreateForm />);
|
||||
expect(html).toContain('data-testid="report-scope-picker-share"');
|
||||
expect(html).toContain('data-testid="report-group-row-identity-share"');
|
||||
// The server refuses the leaf with a 422; a control that cannot be
|
||||
// honoured has no business rendering, so the identity group is one leaf
|
||||
// wide here and two wide in the export panel. Nothing in it is on: a new
|
||||
// link starts with an empty scope on every surface.
|
||||
const row = html.match(
|
||||
/data-testid="report-group-row-identity-share"[\s\S]*?<\/div>/,
|
||||
)?.[0];
|
||||
expect(row).toBeDefined();
|
||||
expect(row).toContain(">0/1<");
|
||||
// The fenced tier renders its leaves unconditionally; none is checked.
|
||||
expect(html).toContain('data-testid="report-leaf-MOOD-share"');
|
||||
expect(html).not.toContain('data-testid="report-leaf-INSURANCE-share"');
|
||||
});
|
||||
|
||||
it("hides the scope picker entirely for a documents-only share", () => {
|
||||
const html = render(<ShareLinkCreateForm documentOnly />);
|
||||
expect(html).not.toContain('data-testid="report-scope-picker-share"');
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,6 @@ const FIXTURE_LINKS = [
|
||||
label: "Cardiology referral",
|
||||
rangeStart: "2026-01-01T00:00:00.000Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: ["Patient", "Observation"],
|
||||
allowFhirApi: true,
|
||||
documentCount: 2,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
@@ -48,8 +46,6 @@ const FIXTURE_LINKS = [
|
||||
label: "Old GP link",
|
||||
rangeStart: "2026-01-01T00:00:00.000Z",
|
||||
rangeEnd: null,
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
documentCount: 0,
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
@@ -100,8 +96,9 @@ describe("<SharingSection> — owner share-link surface (C7)", () => {
|
||||
// The list response shape carries no token; the markup must not surface
|
||||
// an `hls_` string anywhere.
|
||||
expect(html).not.toContain("hls_");
|
||||
// The FHIR badge surfaces for the FHIR-enabled link.
|
||||
expect(html).toContain("FHIR");
|
||||
// No FHIR badge: the link has never served FHIR, so advertising
|
||||
// it on the owner's list was the last place the promise still showed.
|
||||
expect(html).not.toContain("FHIR");
|
||||
});
|
||||
|
||||
it("folds revoked links into the inactive list (not the active list)", () => {
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CalendarHeart,
|
||||
Download,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
@@ -44,9 +42,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImportPanel } from "@/components/settings/import-panel";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
type ExportFormat = "CSV" | "JSON" | "FHIR";
|
||||
|
||||
@@ -96,11 +93,6 @@ export function ExportSection() {
|
||||
<MedicationsCsvCard />
|
||||
<MoodCsvCard />
|
||||
<FullBackupCard />
|
||||
{/* R30 — cycle export, gated on cycle tracking being enabled.
|
||||
The card mounts only for accounts with cycle data; it reuses
|
||||
the health-record FHIR export with the reproductive section
|
||||
opted in, so there is no separate backend path. */}
|
||||
<CycleExportCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -556,111 +548,3 @@ function FullBackupCard() {
|
||||
}
|
||||
|
||||
// ─────────────────────────── Cycle export (gated) ───────────────────────────
|
||||
|
||||
/**
|
||||
* R30 — cycle / reproductive-health export, GATED on cycle tracking
|
||||
* being enabled for the account. A standalone cycle export was removed
|
||||
* from the cycle settings in v1.15.4 (it lives in the full backup); this
|
||||
* surfaces an explicit, opt-in cycle export here for accounts that track
|
||||
* a cycle. It reuses the flagship `/api/export/health-record` route with
|
||||
* the reproductive section opted in and every other section off — no
|
||||
* separate backend path. The card does not render at all when cycle
|
||||
* tracking is off, so a non-cycle account never sees it.
|
||||
*/
|
||||
function CycleExportCard() {
|
||||
const { t } = useTranslations();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The ungated prefs read tells us whether cycle tracking is enabled
|
||||
// without tripping the cycle gate's 403.
|
||||
const prefsQuery = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
|
||||
return apiGet<{ cycleTrackingEnabled: boolean }>(
|
||||
"/api/auth/me/cycle-prefs",
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Gate: render nothing until we know cycle is on. A failed/absent read
|
||||
// keeps the card hidden (fail-closed) so it never appears spuriously.
|
||||
if (prefsQuery.data?.cycleTrackingEnabled !== true) return null;
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format: "fhir",
|
||||
sections: {
|
||||
vitals: {},
|
||||
cardioFitness: {},
|
||||
activity: {},
|
||||
glucose: false,
|
||||
medications: {},
|
||||
mood: false,
|
||||
bmi: false,
|
||||
cycle: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-cycle-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ExportCardShell
|
||||
testId="export-card-cycle"
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.sections.export.cards.cycle.title")}
|
||||
description={t("settings.sections.export.cards.cycle.description")}
|
||||
format="FHIR"
|
||||
footer={
|
||||
<Button
|
||||
data-testid="export-action-cycle"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.actions.download")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ExportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { Share2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
|
||||
export function GesundheitsakteSection() {
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.7.0 — Settings → Export → Health Record export panel.
|
||||
*
|
||||
* One surface that drives `POST /api/export/health-record`: a format
|
||||
* toggle (PDF / FHIR / package) that drives the visible options below it
|
||||
* (per the "dropdown drives the form, no top/bottom split" preference),
|
||||
* a date-range picker, grouped data-section toggles, and the generate
|
||||
* action that streams the artefact as a download.
|
||||
*
|
||||
* Mood stays default-OFF everywhere (privacy). PDF-only fields (practice
|
||||
* name, charts) hide when the format is FHIR. The ePA-compat note shows
|
||||
* for the FHIR + package formats.
|
||||
*
|
||||
* v1.18.1 (D9) — the panel renders as a normal settings card (the v1.12
|
||||
* gradient/glow hero treatment was dropped: the gradient read too crass next
|
||||
* to the rest of the settings surface). The grouped data-section toggles live
|
||||
* behind a collapsible disclosure, collapsed by default, so the panel opens
|
||||
* compact instead of as a long always-expanded checklist.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ExportFormat = "pdf" | "fhir" | "package";
|
||||
|
||||
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
|
||||
|
||||
interface SectionState {
|
||||
weight: boolean;
|
||||
bp: boolean;
|
||||
pulse: boolean;
|
||||
oxygenSaturation: boolean;
|
||||
bodyFat: boolean;
|
||||
bodyComposition: boolean;
|
||||
restingHeartRate: boolean;
|
||||
hrv: boolean;
|
||||
vo2max: boolean;
|
||||
steps: boolean;
|
||||
distance: boolean;
|
||||
sleep: boolean;
|
||||
glucose: boolean;
|
||||
medList: boolean;
|
||||
compliance: boolean;
|
||||
mood: boolean;
|
||||
bmi: boolean;
|
||||
labs: boolean;
|
||||
allergies: boolean;
|
||||
familyHistory: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SECTIONS: SectionState = {
|
||||
weight: true,
|
||||
bp: true,
|
||||
pulse: true,
|
||||
oxygenSaturation: true,
|
||||
bodyFat: true,
|
||||
bodyComposition: false,
|
||||
restingHeartRate: false,
|
||||
hrv: false,
|
||||
vo2max: false,
|
||||
steps: false,
|
||||
distance: false,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
medList: true,
|
||||
compliance: true,
|
||||
mood: false, // privacy default
|
||||
bmi: true,
|
||||
labs: true,
|
||||
allergies: true,
|
||||
familyHistory: true,
|
||||
};
|
||||
|
||||
function buildSelectionSections(s: SectionState) {
|
||||
return {
|
||||
vitals: {
|
||||
weight: s.weight,
|
||||
bp: s.bp,
|
||||
pulse: s.pulse,
|
||||
oxygenSaturation: s.oxygenSaturation,
|
||||
bodyFat: s.bodyFat,
|
||||
bodyComposition: s.bodyComposition,
|
||||
},
|
||||
cardioFitness: {
|
||||
restingHeartRate: s.restingHeartRate,
|
||||
hrv: s.hrv,
|
||||
vo2max: s.vo2max,
|
||||
},
|
||||
activity: {
|
||||
steps: s.steps,
|
||||
distance: s.distance,
|
||||
sleep: s.sleep,
|
||||
},
|
||||
glucose: s.glucose,
|
||||
medications: { list: s.medList, compliance: s.compliance },
|
||||
mood: s.mood,
|
||||
bmi: s.bmi,
|
||||
labs: s.labs,
|
||||
allergies: s.allergies,
|
||||
familyHistory: s.familyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [format, setFormat] = useState<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(90);
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [includeCharts, setIncludeCharts] = useState(true);
|
||||
const [includeAiSummary, setIncludeAiSummary] = useState(false);
|
||||
const [sections, setSections] = useState<SectionState>(DEFAULT_SECTIONS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The included-data checklist is a disclosure, collapsed by default —
|
||||
// the panel opens compact instead of as a long always-expanded list.
|
||||
const [includedDataOpen, setIncludedDataOpen] = useState(false);
|
||||
const includedDataPanelId = useId();
|
||||
|
||||
// Seed the practice name from the last export once the auth payload lands.
|
||||
// `useAuth` resolves asynchronously, so a `useState` initializer would read
|
||||
// `undefined` and stick at empty. Store-the-id-alongside-the-state (the same
|
||||
// pattern the account form uses) syncs during render instead of in an
|
||||
// effect, and the per-user gate means a late re-resolve never clobbers a
|
||||
// field the user is already typing into.
|
||||
if (user && user.id !== practiceNameSeededUserId) {
|
||||
setPracticeNameSeededUserId(user.id);
|
||||
setPracticeName(user.lastReportPracticeName ?? "");
|
||||
}
|
||||
|
||||
const isPdfLike = format === "pdf" || format === "package";
|
||||
const isFhirLike = format === "fhir" || format === "package";
|
||||
|
||||
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
|
||||
count: EXPORT_FORMATS.length,
|
||||
selectedIndex: EXPORT_FORMATS.indexOf(format),
|
||||
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
|
||||
});
|
||||
|
||||
function toggle(key: keyof SectionState) {
|
||||
setSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
// The single "Medikamente" control gates the whole medication block
|
||||
// (list + compliance) so unchecking it actually excludes all medication
|
||||
// data from the export — matching the iOS five-section model.
|
||||
function toggleMedications() {
|
||||
setSections((prev) => {
|
||||
const next = !(prev.medList && prev.compliance);
|
||||
return { ...prev, medList: next, compliance: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
// Carry the active in-app locale so the generated artefact matches
|
||||
// the UI language instead of falling back to the browser's
|
||||
// Accept-Language header on the server.
|
||||
locale,
|
||||
range: { days },
|
||||
practiceName: practiceName.trim() || undefined,
|
||||
includeCharts,
|
||||
includeAiSummary,
|
||||
sections: buildSelectionSections(sections),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`${res.status}`);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
// The route just persisted the practice name, so the cached `/me`
|
||||
// payload is stale. Refresh it, or a remount of this panel would seed
|
||||
// from the previous value.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="health-record-export-title"
|
||||
data-testid="health-record-export-panel"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
className="mb-4"
|
||||
icon={FileText}
|
||||
titleId="health-record-export-title"
|
||||
title={t("settings.healthRecord.title")}
|
||||
description={t("settings.healthRecord.description")}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Format + range share a row on desktop so the panel opens
|
||||
denser; on mobile they stack. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="mb-1 text-sm font-medium">
|
||||
{t("settings.healthRecord.format")}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{EXPORT_FORMATS.map((f, index) => (
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === f}
|
||||
variant={format === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFormat(f)}
|
||||
{...getFormatRadioProps(index)}
|
||||
>
|
||||
{t(`settings.healthRecord.format_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
|
||||
<NativeSelect
|
||||
id="hr-range"
|
||||
value={String(days)}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
<option value="90">{t("settings.healthRecord.range90")}</option>
|
||||
<option value="180">{t("settings.healthRecord.range180")}</option>
|
||||
<option value="365">{t("settings.healthRecord.range365")}</option>
|
||||
</NativeSelect>
|
||||
{/* Entry point into the document vault for the report period
|
||||
(navigation only — attaching documents to the PDF is a later
|
||||
phase). Year granularity is what the vault's URL filters
|
||||
offer; the current year covers the bulk of any range. Only
|
||||
rendered when the documents module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href={`/documents?year=${new Date().getFullYear()}`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Practice name — PDF only */}
|
||||
{isPdfLike && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-practice">
|
||||
{t("settings.healthRecord.practiceName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="hr-practice"
|
||||
value={practiceName}
|
||||
onChange={(e) => setPracticeName(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section groups — collapsible, collapsed by default so the
|
||||
panel opens compact. The disclosure follows the inline
|
||||
aria-expanded/aria-controls pattern used elsewhere in the app
|
||||
(e.g. the insights recommendation cards). */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="sr-only">
|
||||
{t("settings.healthRecord.includedData")}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="health-record-included-data-toggle"
|
||||
aria-expanded={includedDataOpen}
|
||||
aria-controls={includedDataPanelId}
|
||||
onClick={() => setIncludedDataOpen((v) => !v)}
|
||||
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("settings.healthRecord.includedData")}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
includedDataOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{includedDataOpen && (
|
||||
<div
|
||||
id={includedDataPanelId}
|
||||
data-testid="health-record-included-data-panel"
|
||||
className="animate-insight-in space-y-3"
|
||||
style={{ animationDuration: "200ms" }}
|
||||
>
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("settings.healthRecord.groupVitals")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.weight")}
|
||||
checked={sections.weight}
|
||||
onToggle={() => toggle("weight")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bp")}
|
||||
checked={sections.bp}
|
||||
onToggle={() => toggle("bp")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.pulse")}
|
||||
checked={sections.pulse}
|
||||
onToggle={() => toggle("pulse")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.oxygenSaturation")}
|
||||
checked={sections.oxygenSaturation}
|
||||
onToggle={() => toggle("oxygenSaturation")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bodyFat")}
|
||||
checked={sections.bodyFat}
|
||||
onToggle={() => toggle("bodyFat")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bodyComposition")}
|
||||
checked={sections.bodyComposition}
|
||||
onToggle={() => toggle("bodyComposition")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("settings.healthRecord.groupCardio")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.restingHeartRate")}
|
||||
checked={sections.restingHeartRate}
|
||||
onToggle={() => toggle("restingHeartRate")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.hrv")}
|
||||
checked={sections.hrv}
|
||||
onToggle={() => toggle("hrv")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.vo2max")}
|
||||
checked={sections.vo2max}
|
||||
onToggle={() => toggle("vo2max")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("settings.healthRecord.groupActivity")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.steps")}
|
||||
checked={sections.steps}
|
||||
onToggle={() => toggle("steps")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.distance")}
|
||||
checked={sections.distance}
|
||||
onToggle={() => toggle("distance")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.sleep")}
|
||||
checked={sections.sleep}
|
||||
onToggle={() => toggle("sleep")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.glucose")}
|
||||
checked={sections.glucose}
|
||||
onToggle={() => toggle("glucose")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.medications")}
|
||||
checked={sections.medList && sections.compliance}
|
||||
onToggle={toggleMedications}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bmi")}
|
||||
checked={sections.bmi}
|
||||
onToggle={() => toggle("bmi")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.mood")}
|
||||
checked={sections.mood}
|
||||
onToggle={() => toggle("mood")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.labs")}
|
||||
checked={sections.labs}
|
||||
onToggle={() => toggle("labs")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.allergies")}
|
||||
checked={sections.allergies}
|
||||
onToggle={() => toggle("allergies")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.familyHistory")}
|
||||
checked={sections.familyHistory}
|
||||
onToggle={() => toggle("familyHistory")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPdfLike && (
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.includeCharts")}
|
||||
checked={includeCharts}
|
||||
onToggle={() => setIncludeCharts((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
{isPdfLike && (
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.includeAiSummary")}
|
||||
checked={includeAiSummary}
|
||||
onToggle={() => setIncludeAiSummary((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* The FHIR note and the generate action share the footer row so
|
||||
the panel ends compact instead of stacking a note above a
|
||||
right-aligned button. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{isFhirLike ? (
|
||||
<p className="text-muted-foreground max-w-md text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
className="ml-auto min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.healthRecord.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("settings.healthRecord.error", { code: error })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One group row in the scope picker.
|
||||
*
|
||||
* The row is a `ListRow` carrying a group checkbox, the group name, a count
|
||||
* chip, and a disclosure that opens the leaf grid. The group checkbox is one
|
||||
* control for up to sixteen leaves, which is what makes the repeat visit two
|
||||
* interactions; the chevron is the escape hatch and never sits on the fast
|
||||
* path.
|
||||
*
|
||||
* `mixed` is a derived display state and never a click target: clicking a
|
||||
* partly-selected group turns it fully on. Only leaves are ever stored.
|
||||
*/
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ReportGroupId,
|
||||
ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import type { GroupCheckState } from "@/lib/report-selection/panel-state";
|
||||
|
||||
import { LeafGrid } from "./leaf-grid";
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function GroupRow({
|
||||
t,
|
||||
surface,
|
||||
id,
|
||||
labelKey,
|
||||
leaves,
|
||||
selected,
|
||||
checkState,
|
||||
count,
|
||||
open,
|
||||
panelId,
|
||||
onToggleGroup,
|
||||
onToggleLeaf,
|
||||
onToggleOpen,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
surface: ReportScopeSurface;
|
||||
id: ReportGroupId;
|
||||
labelKey: string;
|
||||
leaves: readonly ReportLeafId[];
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
checkState: GroupCheckState;
|
||||
count: { on: number; total: number };
|
||||
open: boolean;
|
||||
panelId: string;
|
||||
onToggleGroup: () => void;
|
||||
onToggleLeaf: (leaf: ReportLeafId) => void;
|
||||
onToggleOpen: () => void;
|
||||
}) {
|
||||
const label = t(labelKey);
|
||||
return (
|
||||
<ListRow
|
||||
className="space-y-2"
|
||||
data-testid={`report-group-row-${id}-${surface}`}
|
||||
>
|
||||
<div className="flex min-h-11 items-center gap-3 sm:min-h-9">
|
||||
<Checkbox
|
||||
checked={
|
||||
checkState === "all"
|
||||
? true
|
||||
: checkState === "mixed"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={onToggleGroup}
|
||||
aria-label={label}
|
||||
data-testid={`report-group-check-${id}-${surface}`}
|
||||
/>
|
||||
<span className="text-foreground min-w-0 flex-1 text-sm">{label}</span>
|
||||
<TagChip>
|
||||
{t("reportSelection.groupCount", {
|
||||
on: count.on,
|
||||
total: count.total,
|
||||
})}
|
||||
</TagChip>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-label={t("reportSelection.groupDisclosure", { group: label })}
|
||||
onClick={onToggleOpen}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 flex size-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div id={panelId}>
|
||||
<LeafGrid
|
||||
t={t}
|
||||
surface={surface}
|
||||
leaves={leaves}
|
||||
selected={selected}
|
||||
onToggle={onToggleLeaf}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The per-leaf checkbox grid inside an expanded group.
|
||||
*
|
||||
* A dense read-out, not a field grid: `gap-2` on the enforced scale, two
|
||||
* columns from the `sm` breakpoint. Each row clears the tap floor and carries
|
||||
* a stable test id so an end-to-end assertion binds to the leaf, not to text
|
||||
* that a breakpoint may hide. The id ends with the mounting surface, because
|
||||
* one page carries two pickers.
|
||||
*/
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MEASUREMENT_TYPE_ICONS } from "@/components/measurements/measurement-list-meta";
|
||||
import {
|
||||
leafLabelKey,
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function LeafGrid({
|
||||
t,
|
||||
surface,
|
||||
leaves,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
surface: ReportScopeSurface;
|
||||
leaves: readonly ReportLeafId[];
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
onToggle: (leaf: ReportLeafId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{leaves.map((leaf) => {
|
||||
const Icon = MEASUREMENT_TYPE_ICONS[leaf];
|
||||
return (
|
||||
<label
|
||||
key={leaf}
|
||||
data-testid={`report-leaf-${leaf}-${surface}`}
|
||||
className="flex min-h-11 items-center gap-3 text-sm sm:min-h-9"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(leaf)}
|
||||
onCheckedChange={() => onToggle(leaf)}
|
||||
/>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-foreground min-w-0 flex-1">
|
||||
{t(leafLabelKey(leaf))}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The one scope picker. Both surfaces that choose what a report contains — the
|
||||
* export panel and the share-link create form — mount this exact component, so
|
||||
* two selection UIs cannot drift apart.
|
||||
*
|
||||
* Nothing is ever selected for the user. A picker with an empty selection
|
||||
* stays empty until someone ticks something, and the one shipped template is
|
||||
* reachable only through the button at the top that carries its name. One
|
||||
* click still gets a complete doctor's report, so nothing got slower — but the
|
||||
* click happened, and that is the whole difference between a chosen scope and
|
||||
* a scope somebody merely failed to untick.
|
||||
*
|
||||
* Twelve group rows plus a fenced tier below a separator. The fenced tier has
|
||||
* NO group checkbox: there is no control anywhere in this component that can
|
||||
* turn on more than one sensitive leaf at a time, and there is no select-all.
|
||||
* The fence uses no colour to mark itself — a separator, a heading, and
|
||||
* per-leaf controls. A red tint would be decoration; status colour is reserved
|
||||
* for meaning.
|
||||
*
|
||||
* The component is controlled: it owns no selection state, only which rows are
|
||||
* expanded and what each group looked like before it was switched off.
|
||||
*
|
||||
* `surface` is required because Settings → Gesundheitsakte mounts the picker
|
||||
* twice on one page. Every test id under the picker ends with it, so each mount
|
||||
* stays uniquely addressable no matter how many surfaces adopt the component.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
REPORT_GROUPS,
|
||||
SENSITIVE_GROUP_ID,
|
||||
type ReportGroupId,
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import {
|
||||
groupCheckState,
|
||||
groupCount,
|
||||
toggleGroup,
|
||||
toggleLeaf,
|
||||
} from "@/lib/report-selection/panel-state";
|
||||
import {
|
||||
STANDARD_TEMPLATE_LABEL_KEY,
|
||||
standardTemplateFor,
|
||||
} from "@/lib/report-selection/template";
|
||||
|
||||
import { GroupRow } from "./group-row";
|
||||
import { LeafGrid } from "./leaf-grid";
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function ReportScopePicker({
|
||||
surface,
|
||||
selected,
|
||||
onChange,
|
||||
hiddenLeaves,
|
||||
}: {
|
||||
/**
|
||||
* The mount this picker belongs to. Names every test id under it, so the two
|
||||
* pickers that share the Gesundheitsakte page never collide.
|
||||
*/
|
||||
surface: ReportScopeSurface;
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
onChange: (next: Set<ReportLeafId>) => void;
|
||||
/**
|
||||
* Leaves this surface must not offer at all. The share-link form passes the
|
||||
* insurance leaf: the server refuses it with a 422, and a control that
|
||||
* cannot be honoured has no business rendering.
|
||||
*/
|
||||
hiddenLeaves?: readonly ReportLeafId[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const idPrefix = useId();
|
||||
const [openGroups, setOpenGroups] = useState<ReadonlySet<ReportGroupId>>(
|
||||
new Set(),
|
||||
);
|
||||
// What each group looked like before it was switched off, so switching it
|
||||
// back on inside one session restores the pattern instead of flooding it.
|
||||
const [remembered, setRemembered] = useState<ReadonlySet<ReportLeafId>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const hidden = new Set(hiddenLeaves ?? []);
|
||||
const groups = REPORT_GROUPS.map((group) => ({
|
||||
...group,
|
||||
leaves: group.leaves.filter((leaf) => !hidden.has(leaf)),
|
||||
})).filter((group) => group.leaves.length > 0);
|
||||
|
||||
const plainGroups = groups.filter((g) => g.id !== SENSITIVE_GROUP_ID);
|
||||
const sensitiveGroup = groups.find((g) => g.id === SENSITIVE_GROUP_ID);
|
||||
|
||||
const handleLeaf = (leaf: ReportLeafId) => {
|
||||
setRemembered(new Set(selected));
|
||||
onChange(toggleLeaf(selected, leaf));
|
||||
};
|
||||
|
||||
const handleGroup = (
|
||||
group: ReportGroupId,
|
||||
leaves: readonly ReportLeafId[],
|
||||
) => {
|
||||
setRemembered(new Set(selected));
|
||||
onChange(toggleGroup(selected, group, leaves, remembered));
|
||||
};
|
||||
|
||||
const hintId = `${idPrefix}-standard`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
|
||||
{/* The one-click way to a complete report. An inline action row
|
||||
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
|
||||
It is offered, never applied — and it stays offered so a person can
|
||||
come back to the standard set after experimenting. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p
|
||||
id={hintId}
|
||||
className="text-muted-foreground min-w-0 flex-1 text-xs"
|
||||
data-testid={`report-standard-hint-${surface}`}
|
||||
>
|
||||
{t("reportSelection.standardHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
aria-describedby={hintId}
|
||||
data-testid={`report-apply-standard-${surface}`}
|
||||
onClick={() => onChange(new Set(standardTemplateFor(hiddenLeaves)))}
|
||||
>
|
||||
{t("reportSelection.applyStandard", {
|
||||
template: t(STANDARD_TEMPLATE_LABEL_KEY),
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{plainGroups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.id}
|
||||
t={t}
|
||||
surface={surface}
|
||||
id={group.id}
|
||||
labelKey={group.labelKey}
|
||||
leaves={group.leaves}
|
||||
selected={selected}
|
||||
checkState={groupCheckState(group.leaves, selected)}
|
||||
count={groupCount(group.leaves, selected)}
|
||||
open={openGroups.has(group.id)}
|
||||
panelId={`${idPrefix}-${group.id}`}
|
||||
onToggleGroup={() => handleGroup(group.id, group.leaves)}
|
||||
onToggleLeaf={handleLeaf}
|
||||
onToggleOpen={() =>
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.id)) next.delete(group.id);
|
||||
else next.add(group.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sensitiveGroup ? (
|
||||
<div data-testid={`report-sensitive-tier-${surface}`}>
|
||||
<Separator className="my-4" />
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
||||
{t(sensitiveGroup.labelKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
{t("reportSelection.sensitiveHint")}
|
||||
</p>
|
||||
<LeafGrid
|
||||
t={t}
|
||||
surface={surface}
|
||||
leaves={sensitiveGroup.leaves}
|
||||
selected={selected}
|
||||
onToggle={handleLeaf}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The one line that says what the artefact will contain.
|
||||
*
|
||||
* This is the consent check: reading it IS the review. Fenced inclusions are
|
||||
* restated by name rather than folded into the count, because a number cannot
|
||||
* be checked at a glance and "incl. Mood, PHQ-9" can.
|
||||
*
|
||||
* On an empty scope it is also the reason the generate / create control is
|
||||
* disabled, which is why it takes an `id`: the control points its
|
||||
* `aria-describedby` here, so "why can I not press this" has an answer in the
|
||||
* accessibility tree as well as on screen.
|
||||
*
|
||||
* Meta tier — one sentence about the form beside it, not content.
|
||||
*
|
||||
* One per picker, so the test id ends with the same surface its picker does.
|
||||
*/
|
||||
import { scopeSummary } from "@/lib/report-selection/panel-state";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function ScopeSummary({
|
||||
t,
|
||||
id,
|
||||
surface,
|
||||
selected,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
/** Referenced by the control this line explains, via `aria-describedby`. */
|
||||
id?: string;
|
||||
surface: ReportScopeSurface;
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
}) {
|
||||
const summary = scopeSummary(selected);
|
||||
if (summary.total === 0) {
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid={`report-scope-summary-${surface}`}
|
||||
>
|
||||
{t("reportSelection.scopeNone")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const base = t("reportSelection.scopeCount", {
|
||||
count: summary.total,
|
||||
groups: summary.groups,
|
||||
});
|
||||
const sensitive =
|
||||
summary.sensitiveLabelKeys.length > 0
|
||||
? t("reportSelection.scopeSensitive", {
|
||||
names: summary.sensitiveLabelKeys.map((key) => t(key)).join(", "),
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid={`report-scope-summary-${surface}`}
|
||||
>
|
||||
{sensitive ? `${base} · ${sensitive}` : base}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings → Export → the health-record export panel.
|
||||
*
|
||||
* One surface driving `POST /api/export/health-record`: a format radio, the
|
||||
* reporting window, the practice line, the scope picker, and the generate
|
||||
* action that streams the artefact as a download.
|
||||
*
|
||||
* Three states, by design:
|
||||
*
|
||||
* 1. First run (no saved selection): NOTHING is selected. The picker opens
|
||||
* expanded so the standard-report button and the empty fenced tier are
|
||||
* both in view, and Generate is disabled until something is ticked. One
|
||||
* click on that button still produces a complete doctor's report, so the
|
||||
* fast path costs one interaction, not a scope nobody chose.
|
||||
* 2. Repeat run: the picker is collapsed behind its disclosure, the saved
|
||||
* selection is loaded, and the scope line under the button says what will
|
||||
* be in the document. Two interactions after navigation, and the reading
|
||||
* is the consent check.
|
||||
* 3. Editing: a group row is one control for up to sixteen leaves; the leaf
|
||||
* grid opens only when the scope actually changes, which is rare.
|
||||
*
|
||||
* The saved selection seeds per-user during render with a stored-id guard —
|
||||
* `useAuth` resolves asynchronously, so a `useState` initialiser would read
|
||||
* `undefined` and stick — and the per-user gate means a late re-resolve never
|
||||
* clobbers edits the user is in the middle of.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import {
|
||||
parseSavedProfile,
|
||||
SAVED_PROFILE_FALLBACK,
|
||||
} from "@/lib/report-selection/profile-shape";
|
||||
import { orderLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { ReportScopePicker } from "./report-scope-picker";
|
||||
import { ScopeSummary } from "./scope-summary";
|
||||
|
||||
type ExportFormat = "pdf" | "fhir" | "package";
|
||||
|
||||
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
|
||||
const PRESET_RANGES = [30, 90, 180, 365] as const;
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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<boolean>(
|
||||
SAVED_PROFILE_FALLBACK.includeCharts,
|
||||
);
|
||||
// Empty until a human ticks something. There is no default selection to get
|
||||
// wrong, because there is no default selection.
|
||||
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
|
||||
() => new Set<ReportLeafId>(),
|
||||
);
|
||||
const [seededUserId, setSeededUserId] = useState<string | null>(null);
|
||||
// A first run opens the picker: with nothing selected, the standard-report
|
||||
// button and the empty groups are the two things the person needs to see.
|
||||
const [pickerOpen, setPickerOpen] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pickerPanelId = useId();
|
||||
const scopeSummaryId = useId();
|
||||
|
||||
if (user && user.id !== seededUserId) {
|
||||
setSeededUserId(user.id);
|
||||
setPracticeName(user.lastReportPracticeName ?? "");
|
||||
const saved = parseSavedProfile(user.reportSelection);
|
||||
if (saved) {
|
||||
setSelected(new Set(saved.leaves as ReportLeafId[]));
|
||||
setFormat(saved.format);
|
||||
setDays(saved.rangeDays);
|
||||
setIncludeCharts(saved.includeCharts);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isPdfLike = format === "pdf" || format === "package";
|
||||
const isFhirLike = format === "fhir" || format === "package";
|
||||
|
||||
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
|
||||
count: EXPORT_FORMATS.length,
|
||||
selectedIndex: EXPORT_FORMATS.indexOf(format),
|
||||
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
|
||||
});
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const range =
|
||||
customRange && startDate && endDate
|
||||
? {
|
||||
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
|
||||
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
|
||||
}
|
||||
: { days };
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
// Carry the active in-app locale so the artefact matches the UI
|
||||
// language instead of falling back to Accept-Language on the server.
|
||||
locale,
|
||||
range,
|
||||
practiceName: practiceName.trim() || undefined,
|
||||
includeCharts,
|
||||
selection: { v: 2, leaves: orderLeaves(selected) },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
res.status === 429
|
||||
? t("settings.healthRecord.errorRateLimit")
|
||||
: res.status === 403
|
||||
? t("settings.healthRecord.errorModuleDisabled")
|
||||
: t("settings.healthRecord.errorGeneric"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
// The route just persisted the practice name and the selection, so the
|
||||
// cached `/me` payload is stale. Refresh it, or a remount would seed from
|
||||
// the previous values.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="health-record-export-title"
|
||||
data-testid="health-record-export-panel"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
className="mb-4"
|
||||
icon={FileText}
|
||||
titleId="health-record-export-title"
|
||||
title={t("settings.healthRecord.title")}
|
||||
description={t("settings.healthRecord.description")}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Format + range share a row on desktop; on mobile they stack. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="mb-1 text-sm font-medium">
|
||||
{t("settings.healthRecord.format")}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{EXPORT_FORMATS.map((f, index) => (
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === f}
|
||||
variant={format === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFormat(f)}
|
||||
{...getFormatRadioProps(index)}
|
||||
>
|
||||
{t(`settings.healthRecord.format_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
|
||||
<NativeSelect
|
||||
id="hr-range"
|
||||
value={customRange ? "custom" : String(days)}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "custom") {
|
||||
setCustomRange(true);
|
||||
return;
|
||||
}
|
||||
setCustomRange(false);
|
||||
setDays(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
{PRESET_RANGES.map((preset) => (
|
||||
<option key={preset} value={String(preset)}>
|
||||
{t(`settings.healthRecord.range${preset}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">
|
||||
{t("settings.healthRecord.rangeCustom")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{/* Entry point into the document vault for the report period
|
||||
(navigation only). Only rendered when the module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href={`/documents?year=${new Date().getFullYear()}`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customRange ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range-start">
|
||||
{t("settings.healthRecord.rangeCustomStart")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="hr-range-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
max={endDate || undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range-end">
|
||||
{t("settings.healthRecord.rangeCustomEnd")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="hr-range-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Practice name — PDF only */}
|
||||
{isPdfLike && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-practice">
|
||||
{t("settings.healthRecord.practiceName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="hr-practice"
|
||||
value={practiceName}
|
||||
onChange={(e) => setPracticeName(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="sr-only">
|
||||
{t("settings.healthRecord.includedData")}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="health-record-included-data-toggle"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-controls={pickerPanelId}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("settings.healthRecord.includedData")}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
pickerOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{pickerOpen && (
|
||||
<div
|
||||
id={pickerPanelId}
|
||||
data-testid="health-record-included-data-panel"
|
||||
className="animate-insight-in space-y-3"
|
||||
style={{ animationDuration: "200ms" }}
|
||||
>
|
||||
<ReportScopePicker
|
||||
surface="export"
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
{isPdfLike ? (
|
||||
<label className="flex min-h-11 items-center justify-between gap-3 text-sm sm:min-h-9">
|
||||
<span className="text-foreground">
|
||||
{t("settings.healthRecord.includeCharts")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={includeCharts}
|
||||
onCheckedChange={() => setIncludeCharts((v) => !v)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* The FHIR note and the generate action share the footer row. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<ScopeSummary
|
||||
t={t}
|
||||
id={scopeSummaryId}
|
||||
surface="export"
|
||||
selected={selected}
|
||||
/>
|
||||
{isFhirLike ? (
|
||||
<p className="text-muted-foreground max-w-md text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{/* An empty scope cannot be generated: there is no document to make.
|
||||
The scope line beside the button carries the reason and the way
|
||||
out of it, and describes the button so the disabled state is not
|
||||
silent for a screen reader. */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy || selected.size === 0}
|
||||
aria-describedby={scopeSummaryId}
|
||||
data-testid="health-record-generate"
|
||||
className="ml-auto min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.healthRecord.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Which surface a scope picker is mounted on.
|
||||
*
|
||||
* Settings → Gesundheitsakte carries BOTH mounts on one page: the export panel
|
||||
* at the top and the share-link create form under `#sharing`. They are the same
|
||||
* component by design — two selection UIs must not drift apart — but that made
|
||||
* every test id inside the picker resolve twice on that page, so nothing in it
|
||||
* was addressable.
|
||||
*
|
||||
* The surface is therefore required, not optional, and every test id under the
|
||||
* picker ends with it: `report-group-row-vitals-export` and
|
||||
* `report-group-row-vitals-share` are two different controls and read as two
|
||||
* different controls. A third mount adds a value here and stays addressable by
|
||||
* construction.
|
||||
*/
|
||||
export type ReportScopeSurface = "export" | "share";
|
||||
@@ -4,9 +4,10 @@
|
||||
* The clinician share-link CREATE flow, extracted so both surfaces mount the
|
||||
* exact same form: Settings → Sharing (the owner section) and the document
|
||||
* detail sheet's "Share" action. A share link is a time-boxed, scope-frozen,
|
||||
* read-only view of the owner's own record at `/c/<token>`, optionally
|
||||
* exposing a scoped read-only FHIR face and a hand-picked, frozen-at-create
|
||||
* document set.
|
||||
* read-only view of the owner's own record at `/c/<token>`, plus a hand-picked,
|
||||
* frozen-at-create document set. It serves a rendered page and those documents
|
||||
* and nothing else. A FHIR toggle and a resource-type picker were removed
|
||||
* that configured an endpoint no route ever served.
|
||||
*
|
||||
* The raw `hls_` token, the passphrase, and the `#k=` QR are returned EXACTLY
|
||||
* ONCE on create (the server stores only hashes); this component is the single
|
||||
@@ -20,32 +21,25 @@
|
||||
* document-launched flow seeds the document the user was looking at) and
|
||||
* `initialLabel` to pre-fill the label.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ShareDocumentPicker,
|
||||
type PickedDocument,
|
||||
} from "@/components/settings/share-document-picker";
|
||||
import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker";
|
||||
import { ScopeSummary } from "@/components/settings/report-selection/scope-summary";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import { orderLeaves } from "@/lib/report-selection/selection";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
|
||||
const RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
|
||||
export const MAX_DAYS = 90;
|
||||
const DEFAULT_DAYS = 30;
|
||||
@@ -64,12 +58,17 @@ export interface ShareLinkSummary {
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: string | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
/** v1.18.7 — whether a passphrase second factor guards this link. */
|
||||
protected: boolean;
|
||||
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
|
||||
documentCount: number;
|
||||
/**
|
||||
* Whether this link's frozen scope predates the selection model. Such links
|
||||
* were revoked on upgrade — their scope was "everything except mood and
|
||||
* cycle", which nobody chose — and the sharing list says so, so the owner
|
||||
* re-mints rather than wondering why a working link stopped.
|
||||
*/
|
||||
needsReselection: boolean;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
@@ -84,6 +83,14 @@ export interface ShareLinkSummary {
|
||||
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
|
||||
*/
|
||||
export interface ShareLinkCreated extends ShareLinkSummary {
|
||||
/**
|
||||
* RETIRED, response-only. They configured a FHIR face that was never built;
|
||||
* the request refuses them and the columns are gone. They stay here because
|
||||
* the native client was given this shape and a missing non-optional key
|
||||
* fails a whole Swift decode — see #70 in the native repository.
|
||||
*/
|
||||
allowFhirApi: boolean;
|
||||
resourceTypes: string[];
|
||||
token: string;
|
||||
passphrase: string;
|
||||
shareUrl: string;
|
||||
@@ -99,20 +106,26 @@ export interface ShareLinkCreatePayload {
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: null;
|
||||
resourceTypes: ResourceType[];
|
||||
allowFhirApi: boolean;
|
||||
selection?: { v: 2; leaves: readonly string[] };
|
||||
documentIds?: string[];
|
||||
documentOnly?: boolean;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one leaf a share link may never carry — mirrored from the server, which
|
||||
* refuses it with a 422. The picker hides it rather than rendering a control
|
||||
* that cannot be honoured.
|
||||
*/
|
||||
const SHARE_HIDDEN_LEAVES: readonly ReportLeafId[] = ["INSURANCE"];
|
||||
|
||||
/**
|
||||
* Build the create payload from the resolved form state. Extracted as a pure
|
||||
* function so the two surfaces' scope contract is unit-testable without a DOM.
|
||||
*
|
||||
* The `documentOnly` branch is the privacy-load-bearing one: a document-launched
|
||||
* share posts an EMPTY report scope (`resourceTypes: []`, no `sections`, FHIR
|
||||
* off) plus `documentOnly: true`, and ALWAYS carries the picked document ids —
|
||||
* share posts NO report scope at all plus `documentOnly: true`,
|
||||
* and ALWAYS carries the picked document ids —
|
||||
* so the created link serves the document(s) and nothing else. The record share
|
||||
* (Settings → Sharing) keeps its full scope and only attaches documents when
|
||||
* the owner picked some.
|
||||
@@ -121,8 +134,7 @@ export function buildShareLinkCreatePayload(input: {
|
||||
label: string;
|
||||
rangeDays: number;
|
||||
expiryDays: number;
|
||||
allowFhirApi: boolean;
|
||||
resourceTypes: ResourceType[];
|
||||
leaves: readonly ReportLeafId[];
|
||||
documentIds: string[];
|
||||
documentOnly: boolean;
|
||||
}): ShareLinkCreatePayload {
|
||||
@@ -135,8 +147,6 @@ export function buildShareLinkCreatePayload(input: {
|
||||
if (input.documentOnly) {
|
||||
return {
|
||||
...base,
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
documentOnly: true,
|
||||
// The launched document(s) always ride along — a documents-only share
|
||||
// with no document would be meaningless (the server 422s it).
|
||||
@@ -145,8 +155,7 @@ export function buildShareLinkCreatePayload(input: {
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
resourceTypes: input.resourceTypes,
|
||||
allowFhirApi: input.allowFhirApi,
|
||||
selection: { v: 2, leaves: input.leaves },
|
||||
// Omit the key entirely when nothing is attached (a documents-less record
|
||||
// share stays the default). The server re-validates each id as the caller's
|
||||
// own live document before minting the link.
|
||||
@@ -158,20 +167,29 @@ export function ShareLinkCreateForm({
|
||||
documentOnly = false,
|
||||
initialDocuments,
|
||||
initialLabel,
|
||||
initialRangeDays,
|
||||
initialExpiryDays,
|
||||
onCreated,
|
||||
}: {
|
||||
/**
|
||||
* Documents-only mode (the document-launched flow). Hides the record-scope
|
||||
* controls — FHIR API toggle, FHIR resource-type checkboxes, and the
|
||||
* history-range selector — and posts an empty report scope so the minted
|
||||
* link serves ONLY the attached document(s). Keeps the document picker,
|
||||
* label, expiry, and one-time passphrase reveal.
|
||||
* control — the history-range selector — and posts an empty report scope so
|
||||
* the minted link serves ONLY the attached document(s). Keeps the document
|
||||
* picker, label, expiry, and one-time passphrase reveal.
|
||||
*/
|
||||
documentOnly?: boolean;
|
||||
/** Documents to pre-attach — the document-launched flow seeds the one doc. */
|
||||
initialDocuments?: PickedDocument[];
|
||||
/** Optional pre-filled label (e.g. the document title). */
|
||||
initialLabel?: string;
|
||||
/**
|
||||
* Optional pre-filled window and lifetime, in days. The sharing list passes
|
||||
* these when the owner re-mints a link the selection upgrade closed, so the
|
||||
* only decision left in front of them is the one that link never carried:
|
||||
* what it should contain.
|
||||
*/
|
||||
initialRangeDays?: number;
|
||||
initialExpiryDays?: number;
|
||||
/** Fired after a link is minted, so an outer surface can react if needed. */
|
||||
onCreated?: (created: ShareLinkCreated) => void;
|
||||
}) {
|
||||
@@ -179,21 +197,25 @@ export function ShareLinkCreateForm({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [label, setLabel] = useState(initialLabel ?? "");
|
||||
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
|
||||
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
|
||||
const [allowFhirApi, setAllowFhirApi] = useState(false);
|
||||
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
|
||||
"Patient",
|
||||
"Observation",
|
||||
]);
|
||||
const [rangeDays, setRangeDays] = useState(initialRangeDays ?? DEFAULT_DAYS);
|
||||
const [expiryDays, setExpiryDays] = useState(
|
||||
initialExpiryDays ?? DEFAULT_DAYS,
|
||||
);
|
||||
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
|
||||
initialDocuments ?? [],
|
||||
);
|
||||
// A new link starts with an empty scope. The picker's standard-report button
|
||||
// fills it in one click, minus the leaf a link may not carry — but a link is
|
||||
// handed to another person, so nothing rides on it that was not ticked.
|
||||
const [selectedLeaves, setSelectedLeaves] = useState<
|
||||
ReadonlySet<ReportLeafId>
|
||||
>(() => new Set<ReportLeafId>());
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const scopeSummaryId = useId();
|
||||
|
||||
// Render the QR from the `#k=` deep link once a link is created. The fragment
|
||||
// carries the passphrase, so the QR alone opens the record — it is shown
|
||||
@@ -238,8 +260,7 @@ export function ShareLinkCreateForm({
|
||||
label,
|
||||
rangeDays,
|
||||
expiryDays,
|
||||
allowFhirApi,
|
||||
resourceTypes,
|
||||
leaves: orderLeaves(selectedLeaves),
|
||||
documentIds: selectedDocs.map((d) => d.id),
|
||||
documentOnly,
|
||||
}),
|
||||
@@ -268,12 +289,6 @@ export function ShareLinkCreateForm({
|
||||
},
|
||||
});
|
||||
|
||||
function toggleResourceType(type: ResourceType) {
|
||||
setResourceTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((r) => r !== type) : [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
async function copyValue(value: string, which: "link" | "passphrase") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -344,51 +359,32 @@ export function ShareLinkCreateForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The FHIR API + its resource-type scope expose the health RECORD.
|
||||
A documents-only link serves no record, so both are hidden and the
|
||||
posted scope is forced empty server-side regardless. */}
|
||||
{/* ── What the link contains ───────────────────────────────── */}
|
||||
{!documentOnly ? (
|
||||
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5 pr-3">
|
||||
<Label htmlFor="share-fhir" className="text-sm font-medium">
|
||||
{t("settings.sharing.fhirApi")}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("settings.sharing.scopeTitle")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sharing.fhirApiHint")}
|
||||
{t("settings.sharing.scopeInsuranceExcluded")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-fhir"
|
||||
checked={allowFhirApi}
|
||||
onCheckedChange={setAllowFhirApi}
|
||||
<ReportScopePicker
|
||||
surface="share"
|
||||
selected={selectedLeaves}
|
||||
onChange={setSelectedLeaves}
|
||||
hiddenLeaves={SHARE_HIDDEN_LEAVES}
|
||||
/>
|
||||
<ScopeSummary
|
||||
t={t}
|
||||
id={scopeSummaryId}
|
||||
surface="share"
|
||||
selected={selectedLeaves}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!documentOnly && allowFhirApi && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharing.resourceTypes")}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{RESOURCE_TYPES.map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
htmlFor={`share-rt-${type}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`share-rt-${type}`}
|
||||
checked={resourceTypes.includes(type)}
|
||||
onCheckedChange={() => toggleResourceType(type)}
|
||||
/>
|
||||
<span className="font-mono text-xs">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ── Attach documents ─────────────────────────────────────── */}
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -461,9 +457,19 @@ export function ShareLinkCreateForm({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* A record link with an empty scope cannot be minted: it would serve
|
||||
a page with nothing on it. The scope line above says so and says
|
||||
what to do about it, and describes the button so the disabled state
|
||||
is not silent for a screen reader. */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!label.trim() ||
|
||||
(!documentOnly && selectedLeaves.size === 0)
|
||||
}
|
||||
aria-describedby={documentOnly ? undefined : scopeSummaryId}
|
||||
data-testid="share-create-submit"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
|
||||
@@ -42,6 +42,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
MAX_DAYS,
|
||||
ShareLinkCreateForm,
|
||||
type ShareLinkSummary,
|
||||
} from "@/components/settings/share-link-create-form";
|
||||
@@ -61,12 +62,49 @@ export function SharingSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a closed link's window and lifetime into a new create form.
|
||||
*
|
||||
* The window is measured from the frozen `rangeStart` to now (a rolling link
|
||||
* has no end), the lifetime from when it was created to when it would have
|
||||
* expired. Both are clamped into the form's own bounds so a very old link
|
||||
* cannot seed an out-of-range value.
|
||||
*/
|
||||
function prefillFrom(link: ShareLinkSummary) {
|
||||
const day = 86_400_000;
|
||||
const rangeDays = Math.max(
|
||||
1,
|
||||
Math.round((Date.now() - new Date(link.rangeStart).getTime()) / day),
|
||||
);
|
||||
const expiryDays = Math.min(
|
||||
MAX_DAYS,
|
||||
Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
(new Date(link.expiresAt).getTime() -
|
||||
new Date(link.createdAt).getTime()) /
|
||||
day,
|
||||
),
|
||||
),
|
||||
);
|
||||
return { key: Date.now(), label: link.label, rangeDays, expiryDays };
|
||||
}
|
||||
|
||||
function ShareLinksCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
// A re-mint carries the closed link's label, window and lifetime across, so
|
||||
// the only thing left to decide is the scope it never had. Keyed so the form
|
||||
// remounts and picks the values up as its initial state.
|
||||
const [prefill, setPrefill] = useState<{
|
||||
key: number;
|
||||
label: string;
|
||||
rangeDays: number;
|
||||
expiryDays: number;
|
||||
} | null>(null);
|
||||
|
||||
const { data: links } = useQuery({
|
||||
queryKey: queryKeys.shareLinks(),
|
||||
@@ -101,7 +139,12 @@ function ShareLinksCard() {
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
|
||||
<ShareLinkCreateForm />
|
||||
<ShareLinkCreateForm
|
||||
key={prefill?.key ?? "new"}
|
||||
initialLabel={prefill?.label}
|
||||
initialRangeDays={prefill?.rangeDays}
|
||||
initialExpiryDays={prefill?.expiryDays}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
@@ -151,11 +194,6 @@ function ShareLinksCard() {
|
||||
{link.documentCount}
|
||||
</Badge>
|
||||
)}
|
||||
{link.allowFhirApi && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
FHIR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -241,11 +279,37 @@ function ShareLinksCard() {
|
||||
{link.label}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
{link.needsReselection
|
||||
? t("settings.sharing.statusNeedsReselect")
|
||||
: link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* A link whose frozen scope predates the selection model.
|
||||
It served a scope nobody chose, so it was closed; the
|
||||
owner re-mints above with the label, window and expiry
|
||||
still in front of them. */}
|
||||
{link.needsReselection ? (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
data-testid={`share-needs-reselect-${link.id}`}
|
||||
>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sharing.reselectReason")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid={`share-reselect-${link.id}`}
|
||||
onClick={() => setPrefill(prefillFrom(link))}
|
||||
>
|
||||
{t("settings.sharing.reselectAction")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -16,6 +16,12 @@ import { cn } from "@/lib/utils";
|
||||
* Mirrors the v1.4.27 shadcn `<Checkbox>` recipe: a Root that draws
|
||||
* the border + checked-state background, an Indicator slot that
|
||||
* shows the lucide `<Check>` when the box is checked.
|
||||
*
|
||||
* The Indicator also draws a `<Minus>` for the indeterminate state, which
|
||||
* Radix's Root accepts natively via `checked="indeterminate"`. A group row
|
||||
* whose leaves are partly selected is a real third state and needs to look
|
||||
* like one — drawing it as unchecked would say "none of this is included",
|
||||
* which on this surface is a false statement about what leaves the instance.
|
||||
*/
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -34,6 +40,7 @@ function Checkbox({
|
||||
// blanket neighbouring controls in dense table rows.
|
||||
"peer border-border/70 relative size-4 shrink-0 rounded border before:absolute before:inset-[-8px] before:content-['']",
|
||||
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
|
||||
"data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-background",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"transition-colors",
|
||||
@@ -45,7 +52,11 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
<Check className="size-3" aria-hidden="true" />
|
||||
{props.checked === "indeterminate" ? (
|
||||
<Minus className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Check className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,13 @@ export interface AuthUser {
|
||||
* user has never set one, or deliberately cleared the line.
|
||||
*/
|
||||
lastReportPracticeName: string | null;
|
||||
/**
|
||||
* The saved report selection blob (`{ v, leaves, format, rangeDays,
|
||||
* includeCharts }`), or `null` when the account has never saved one. Parsed
|
||||
* through `parseSavedProfile` at the one place that reads it, so a drifted
|
||||
* shape resolves to "never saved" rather than to a partial scope.
|
||||
*/
|
||||
reportSelection: unknown;
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
|
||||
* + the per-user opt-in. The cycle nav entry + page hide when false; every
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,6 @@ function liveRow(overrides: Record<string, unknown> = {}) {
|
||||
rangeStart: new Date("2026-01-01T00:00:00.000Z"),
|
||||
rangeEnd: null,
|
||||
sectionsJson: { bp: true },
|
||||
resourceTypes: ["Observation"],
|
||||
allowFhirApi: false,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
@@ -59,7 +57,9 @@ describe("resolveShareToken", () => {
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ownerUserId).toBe("owner-1");
|
||||
expect(ctx?.shareLinkId).toBe("link-1");
|
||||
expect(ctx?.resourceTypes).toEqual(["Observation"]);
|
||||
// The retired FHIR scope no longer rides the resolved context.
|
||||
expect(ctx).not.toHaveProperty("resourceTypes");
|
||||
expect(ctx).not.toHaveProperty("allowFhirApi");
|
||||
// The context must carry ONLY the owner scope — never a session/user/role.
|
||||
expect(ctx).not.toHaveProperty("session");
|
||||
expect(ctx).not.toHaveProperty("user");
|
||||
|
||||
@@ -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,9 +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 },
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT", "PULSE"])),
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
...overrides,
|
||||
} as ShareContext;
|
||||
@@ -69,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 () => {
|
||||
@@ -189,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
|
||||
@@ -212,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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -246,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();
|
||||
@@ -257,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();
|
||||
@@ -266,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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,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();
|
||||
@@ -395,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();
|
||||
@@ -410,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");
|
||||
});
|
||||
|
||||
@@ -422,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();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The shared gate behind a share link's machine-format downloads.
|
||||
*
|
||||
* A practice opens the link, unlocks it, and saves the file into their system.
|
||||
* That is the thing a bearer-authenticated API was supposed to serve and never
|
||||
* did, so this serves it as a download instead: no new credential type, no new
|
||||
* scope, no mint path that does not exist.
|
||||
*
|
||||
* The gate is the document route's, step for step — the same live check, the
|
||||
* same token-scoped unlock cookie, a rate limit before any aggregation, the
|
||||
* same counter bump, the same blunt 404 for every miss class. It differs in
|
||||
* one number: generating a bundle is far more expensive than serving a stored
|
||||
* blob, so it gets its own smaller bucket rather than sharing the document
|
||||
* route's budget.
|
||||
*
|
||||
* Scope cannot exceed the page's: both resolve the SAME frozen `sectionsJson`
|
||||
* through `selectionFromStoredBlob`, and the insurance leaf is refused at
|
||||
* share-link creation, so the insurance number cannot reach this path at all.
|
||||
*/
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
resolveShareGateState,
|
||||
resolveShareToken,
|
||||
} from "@/lib/clinician-share/resolve-share-token";
|
||||
import {
|
||||
unlockCookieName,
|
||||
verifyUnlockValue,
|
||||
} from "@/lib/clinician-share/unlock-cookie";
|
||||
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
|
||||
/**
|
||||
* 20 per hour per link. A practice saves the record once, maybe twice; a
|
||||
* leaked token cannot turn the aggregator into a grinder. Deliberately a
|
||||
* separate bucket from the document route's 300/h and the unlock route's
|
||||
* 10/10min, per the `<surface>:${identifier}` convention.
|
||||
*/
|
||||
const REPORT_DOWNLOAD_LIMIT_PER_HOUR = 20;
|
||||
const REPORT_DOWNLOAD_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** One blunt 404 for every miss class — a probe distinguishes nothing. */
|
||||
function notFound() {
|
||||
return apiError("Not found", 404, {
|
||||
errorCode: "share-link.report.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
export type ReportDownloadResult =
|
||||
| { ok: true; report: DoctorReportData; selection: ReportSelection }
|
||||
| { ok: false; response: Response };
|
||||
|
||||
/**
|
||||
* Run the gate and produce the link's report payload, or the response to
|
||||
* return instead. A documents-only link — or one whose frozen scope this build
|
||||
* cannot read — has no record to download and gets the same flat 404.
|
||||
*/
|
||||
export async function resolveShareReportDownload(
|
||||
request: Request,
|
||||
token: string,
|
||||
format: "fhir" | "pdf",
|
||||
): Promise<ReportDownloadResult> {
|
||||
annotate({ action: { name: "share.report.download" } });
|
||||
|
||||
// 1. Live-gate check first (no counter bump). Malformed / unknown / revoked
|
||||
// / expired all collapse to the same flat 404.
|
||||
const gate = await resolveShareGateState(token);
|
||||
if (!gate) return { ok: false, response: notFound() };
|
||||
|
||||
// 2. Passphrase gate: a protected link serves nothing until the recipient
|
||||
// holds the short-lived, token-scoped unlock cookie. This route lives
|
||||
// under `/c/<token>` so the browser sends that cookie automatically.
|
||||
if (gate.passphraseHash !== null) {
|
||||
const cookieStore = await cookies();
|
||||
const unlockValue = cookieStore.get(
|
||||
unlockCookieName(gate.tokenHash),
|
||||
)?.value;
|
||||
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
|
||||
return { ok: false, response: notFound() };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Per-link rate limit BEFORE any aggregation.
|
||||
const rl = await checkRateLimit(
|
||||
`report-download:${gate.tokenHash}`,
|
||||
REPORT_DOWNLOAD_LIMIT_PER_HOUR,
|
||||
REPORT_DOWNLOAD_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "share-link.report.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return { ok: false, response };
|
||||
}
|
||||
|
||||
// 4. Resolve the full share context. Revocation and expiry are re-enforced
|
||||
// here and the access counters are bumped by the same path that bumps
|
||||
// them for the page.
|
||||
const context = await resolveShareToken(token);
|
||||
if (!context) return { ok: false, response: notFound() };
|
||||
|
||||
// 5. The link's own frozen scope, resolved exactly as the page resolves it,
|
||||
// so the download can never be wider than what the page renders.
|
||||
const view = await loadShareViewData(context);
|
||||
if (view.documentOnly || !view.report) {
|
||||
return { ok: false, response: notFound() };
|
||||
}
|
||||
|
||||
await auditLog("share-link.report.download", {
|
||||
userId: context.ownerUserId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
shareLinkId: context.shareLinkId,
|
||||
format,
|
||||
leafCount: view.selection.leaves.length,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
meta: { format, leafCount: view.selection.leaves.length },
|
||||
});
|
||||
|
||||
return { ok: true, report: view.report, selection: view.selection };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The viewer's locale on a share surface.
|
||||
*
|
||||
* A share link has no session, so the locale comes from the in-app cookie the
|
||||
* recipient's own browser may carry, then from Accept-Language, then from the
|
||||
* default. Extracted from the share page so the PDF download beside it renders
|
||||
* in the same language as the page it was downloaded from.
|
||||
*/
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
export async function resolveShareViewLocale(): Promise<Locale> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return defaultLocale;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -48,10 +49,6 @@ export interface ShareContext {
|
||||
rangeEnd: Date | null;
|
||||
/** Frozen section toggles (the `DoctorReportPrefs` JSON shape). */
|
||||
sectionsJson: unknown;
|
||||
/** FHIR resource types this link may serve (a subset of the catalogue). */
|
||||
resourceTypes: string[];
|
||||
/** Whether the scoped FHIR API is reachable via this link at all. */
|
||||
allowFhirApi: boolean;
|
||||
/**
|
||||
* v1.28.16 — authoritative documents-only flag, frozen at create. When true
|
||||
* the view loader serves ONLY documents and never aggregates a health report,
|
||||
@@ -89,8 +86,6 @@ export async function resolveShareToken(
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
sectionsJson: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
documentOnly: true,
|
||||
expiresAt: true,
|
||||
revokedAt: true,
|
||||
@@ -120,8 +115,6 @@ export async function resolveShareToken(
|
||||
rangeStart: row.rangeStart,
|
||||
rangeEnd: row.rangeEnd,
|
||||
sectionsJson: row.sectionsJson,
|
||||
resourceTypes: row.resourceTypes,
|
||||
allowFhirApi: row.allowFhirApi,
|
||||
documentOnly: row.documentOnly,
|
||||
expiresAt: row.expiresAt,
|
||||
};
|
||||
|
||||
@@ -22,10 +22,10 @@ import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type { DocumentServingClass } from "@/lib/documents/upload-policy";
|
||||
import {
|
||||
hasAnyReportSection,
|
||||
type DoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
import { resolveStoredReportSections } from "@/lib/validations/health-record-export";
|
||||
isEmptySelection,
|
||||
selectionFromStoredBlob,
|
||||
type ReportSelection,
|
||||
} from "@/lib/report-selection/selection";
|
||||
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
|
||||
|
||||
/**
|
||||
@@ -59,8 +59,8 @@ export interface ShareViewData {
|
||||
* the recipient sees only the attached documents.
|
||||
*/
|
||||
report: DoctorReportData | null;
|
||||
/** The resolved section toggles (mood opt-in, defaults otherwise). */
|
||||
sections: DoctorReportPrefs;
|
||||
/** The link's frozen selection, resolved. Empty when it carries no scope. */
|
||||
selection: ReportSelection;
|
||||
/** v1.28 — the hand-picked documents on this link (metadata only). */
|
||||
documents: ShareViewDocument[];
|
||||
/**
|
||||
@@ -94,12 +94,11 @@ function frozenRange(context: ShareContext): DoctorReportRange {
|
||||
export async function loadShareViewData(
|
||||
context: ShareContext,
|
||||
): Promise<ShareViewData> {
|
||||
// The stored `sectionsJson` may be the GROUPED export shape (what the create
|
||||
// schema accepts) or the FLAT doctor-report shape (documents-only / legacy).
|
||||
// `resolveStoredReportSections` folds either down correctly — reading a
|
||||
// grouped blob through the flat parser would silently drop every grouped
|
||||
// toggle and default the section back ON, re-widening a scope the owner froze.
|
||||
const sections = resolveStoredReportSections(context.sectionsJson);
|
||||
// The link's frozen scope. A blob this build cannot read — a legacy shape, a
|
||||
// hand-edited row — resolves to the EMPTY selection, so the link serves
|
||||
// nothing rather than defaulting open. Every such link was revoked by the
|
||||
// v1.32.37 migration; this is the second lock behind that.
|
||||
const selection = selectionFromStoredBlob(context.sectionsJson);
|
||||
const range = frozenRange(context);
|
||||
|
||||
// A documents-only share never aggregates — no health metric is read from the
|
||||
@@ -128,18 +127,16 @@ export async function loadShareViewData(
|
||||
"doctorReport",
|
||||
);
|
||||
const documentOnly =
|
||||
context.documentOnly ||
|
||||
!hasAnyReportSection(sections) ||
|
||||
!reportModuleEnabled;
|
||||
context.documentOnly || isEmptySelection(selection) || !reportModuleEnabled;
|
||||
|
||||
const [report, documents] = await Promise.all([
|
||||
documentOnly
|
||||
? Promise.resolve(null)
|
||||
: collectDoctorReportData(context.ownerUserId, range, { sections }),
|
||||
: collectDoctorReportData(context.ownerUserId, range, selection),
|
||||
loadShareDocuments(context),
|
||||
]);
|
||||
|
||||
return { report, sections, documents, documentOnly };
|
||||
return { report, selection, documents, documentOnly };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-1504
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,13 @@
|
||||
* `%PDF-` byte stream in both environments.
|
||||
*/
|
||||
import { jsPDF } from "jspdf";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
isStructuredLeafId,
|
||||
REPORT_GROUPS,
|
||||
} from "./report-selection/catalogue";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurements/type-label-keys";
|
||||
import { getUnitForType } from "./validations/measurement";
|
||||
import {
|
||||
makeFormatters,
|
||||
DISPLAY_TIMEZONE,
|
||||
@@ -154,20 +160,13 @@ export interface DoctorReportRenderOptions {
|
||||
* Defaults to `true`. Off produces a compact text-only report.
|
||||
*/
|
||||
includeCharts?: boolean;
|
||||
/**
|
||||
* v1.7.0 — optional AI summary text. OUT of the clinical PDF by
|
||||
* default; rendered ONLY when the user explicitly opts in, under a
|
||||
* clearly-labelled "AI summary — not clinically validated" heading.
|
||||
* Null/undefined/empty omits the section entirely.
|
||||
*/
|
||||
aiSummary?: string | null;
|
||||
}
|
||||
|
||||
// Per-vital label keys live in a jsPDF-free module so leaf consumers (the
|
||||
// public clinician share view) can import the map without dragging the PDF
|
||||
// renderer into their graph. Re-exported here so the PDF core stays the
|
||||
// single import surface its existing callers already use.
|
||||
export { DOCTOR_REPORT_TYPE_LABEL_KEYS };
|
||||
// Per-type label keys live in a lucide-free module so leaf consumers — the
|
||||
// public clinician share view, this renderer — can import the map without
|
||||
// dragging icon components into their graph. Re-exported here so the PDF core
|
||||
// stays the single import surface its existing callers already use.
|
||||
export { MEASUREMENT_TYPE_LABEL_KEYS };
|
||||
|
||||
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
@@ -183,50 +182,26 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Vital types rendered in the main vitals table. Body composition
|
||||
* (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings
|
||||
* smart scales report all three together. SpO2 (Withings ScanWatch type
|
||||
* 54, HealthKit, n8n / Health Connect) is rendered last in the same
|
||||
* table for clinical readability. Glucose ships separately via
|
||||
* per-context `glucoseStats`. SLEEP_DURATION rides this table too: the
|
||||
* `sleep` section toggle is default-ON and the data layer reconstructs a
|
||||
* per-night time-asleep total (in minutes) for exactly this row — the
|
||||
* render loop converts it to hours so value and unit (`h`) agree, matching
|
||||
* the FHIR sleep Observation. Activity (steps) stays excluded from a
|
||||
* clinical-focused report.
|
||||
* The measurement table is driven by the selection, not by a whitelist.
|
||||
*
|
||||
* v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
|
||||
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
|
||||
* they are 0–100 composites recomputed nightly from the underlying signals
|
||||
* and carry a "descriptive, not a clinical assessment" disclaimer. They stay
|
||||
* out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
|
||||
* `measurement-type-enum-coverage.test.ts`).
|
||||
* It used to be a fixed nine types, which meant a user could select resting
|
||||
* heart rate, see it in the FHIR bundle and in the share view, and not find it
|
||||
* in the PDF — a per-format divergence on a document whose whole point is that
|
||||
* every format describes the same record. The table now prints every selected
|
||||
* measurement leaf that has data, sub-headed by the same twelve groups the
|
||||
* selection panel shows, so the panel reads as a table of contents for the
|
||||
* artefact.
|
||||
*
|
||||
* v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived
|
||||
* composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an
|
||||
* absolute reading) are excluded for the same reason as the other derived /
|
||||
* lifestyle signals — they are descriptive, not measured clinical vitals.
|
||||
*
|
||||
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
|
||||
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
|
||||
* not a measured clinical vital — so it is excluded too.
|
||||
*
|
||||
* v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
|
||||
* the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
|
||||
* excluded from the vitals table (they ride the module-gated mental-health
|
||||
* export section instead, see `doctor-report-data.ts`).
|
||||
* The aggregator has already applied the selection, so `data.stats` carries
|
||||
* exactly the admitted leaves and nothing here can over-serve.
|
||||
*/
|
||||
export const DOCTOR_REPORT_VITAL_TYPES = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
"SLEEP_DURATION",
|
||||
] as const;
|
||||
export const DOCTOR_REPORT_VITAL_GROUPS = REPORT_GROUPS.map((group) => ({
|
||||
id: group.id,
|
||||
labelKey: group.labelKey,
|
||||
types: group.leaves.filter(
|
||||
(leaf): leaf is MeasurementType => !isStructuredLeafId(leaf),
|
||||
),
|
||||
})).filter((group) => group.types.length > 0);
|
||||
|
||||
/**
|
||||
* Render the doctor report into a `jsPDF` instance.
|
||||
@@ -247,7 +222,6 @@ export function buildDoctorReportPdfDocument(
|
||||
timeFormat = "AUTO",
|
||||
insuranceNumber = null,
|
||||
includeCharts = true,
|
||||
aiSummary = null,
|
||||
} = options;
|
||||
const formatters = makeFormatters(locale, userTz, timeFormat);
|
||||
const num = (value: number, decimals = 1) =>
|
||||
@@ -261,7 +235,12 @@ export function buildDoctorReportPdfDocument(
|
||||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||||
return t("doctorReport.unitSteps");
|
||||
}
|
||||
return staticUnit ?? "";
|
||||
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
|
||||
// Every other type falls back to the canonical unit the measurement
|
||||
// validation layer already records. A type with no recorded unit prints
|
||||
// none — an absent unit is absent, not a guessed symbol.
|
||||
const canonical = getUnitForType(type);
|
||||
return canonical === "unknown" ? "" : canonical;
|
||||
};
|
||||
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
@@ -298,14 +277,13 @@ export function buildDoctorReportPdfDocument(
|
||||
now,
|
||||
insuranceNumber,
|
||||
includeCharts,
|
||||
aiSummary,
|
||||
footerTz,
|
||||
margin,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
contentMaxY,
|
||||
tableBottomMargin,
|
||||
vitalTypes: DOCTOR_REPORT_VITAL_TYPES,
|
||||
vitalGroups: DOCTOR_REPORT_VITAL_GROUPS,
|
||||
unitFor,
|
||||
ensureSpace,
|
||||
};
|
||||
|
||||
@@ -14,19 +14,8 @@ export function buildClinicalRecordsNotesSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
state: DoctorReportPdfCursorState,
|
||||
): DoctorReportPdfCursorState {
|
||||
const {
|
||||
doc,
|
||||
data,
|
||||
t,
|
||||
num,
|
||||
fmtDate,
|
||||
aiSummary,
|
||||
margin,
|
||||
pageWidth,
|
||||
contentMaxY,
|
||||
tableBottomMargin,
|
||||
ensureSpace,
|
||||
} = context;
|
||||
const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } =
|
||||
context;
|
||||
let y = state.y;
|
||||
|
||||
// v1.17.1 — structured lab-results section. Populated when the `labs`
|
||||
@@ -298,49 +287,5 @@ export function buildClinicalRecordsNotesSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
|
||||
// rendered ONLY when the user explicitly opted in. Clearly labelled and
|
||||
// flagged as not clinically validated so a physician never mistakes it
|
||||
// for a machine-generated diagnosis.
|
||||
const aiText = typeof aiSummary === "string" ? aiSummary.trim() : "";
|
||||
if (aiText.length > 0) {
|
||||
// Keep the heading + the first disclaimer line together.
|
||||
y = ensureSpace(y, 5 + 4 + 4.5);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(120, 80, 0);
|
||||
doc.text(t("doctorReport.aiSummaryTitle"), margin, y);
|
||||
y += 5;
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(150, 110, 40);
|
||||
const disclaimer = doc.splitTextToSize(
|
||||
t("doctorReport.aiSummaryDisclaimer"),
|
||||
pageWidth - 2 * margin,
|
||||
);
|
||||
for (const line of disclaimer) {
|
||||
if (y + 4 > contentMaxY) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += 4;
|
||||
}
|
||||
y += 2;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(60, 60, 60);
|
||||
const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin);
|
||||
for (const line of wrapped) {
|
||||
if (y + 4.5 > contentMaxY) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += 4.5;
|
||||
}
|
||||
y += 4;
|
||||
}
|
||||
|
||||
return pdfCursorState(doc, y);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
adherenceRatePercent,
|
||||
type DoctorReportData,
|
||||
} from "../doctor-report-data";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "../doctor-report/type-label-keys";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import type {
|
||||
DoctorReportNumberFormatter,
|
||||
DoctorReportTranslator,
|
||||
@@ -76,7 +77,7 @@ export function buildClinicalSummaryLines(
|
||||
const arrow = trendArrow(series.map((point) => point.value));
|
||||
lines.push(
|
||||
t("doctorReport.summaryTrend", {
|
||||
label: t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
|
||||
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
latest: num(stat.latest, 1),
|
||||
arrow,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "../doctor-report/type-label-keys";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
import {
|
||||
getBmiClassificationKey,
|
||||
getBpClassificationKey,
|
||||
@@ -148,6 +149,30 @@ function drawSparkline(
|
||||
return chartBottom + axisLabelHeight + 2;
|
||||
}
|
||||
|
||||
/** A body row: either six cells, or a full-width group sub-heading. */
|
||||
type VitalTableRow =
|
||||
| string[]
|
||||
| [{ content: string; colSpan: number; styles: Record<string, unknown> }];
|
||||
|
||||
/**
|
||||
* A full-width sub-heading row naming the group the rows under it belong to.
|
||||
* Bold on a light fill, no colour beyond the table's own greys — the heading
|
||||
* carries meaning, not decoration.
|
||||
*/
|
||||
function groupSubHeadRow(label: string): VitalTableRow {
|
||||
return [
|
||||
{
|
||||
content: label,
|
||||
colSpan: 6,
|
||||
styles: {
|
||||
fontStyle: "bold",
|
||||
fillColor: [242, 242, 242],
|
||||
textColor: [30, 30, 30],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildMeasurementsChartsSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
state: DoctorReportPdfCursorState,
|
||||
@@ -165,7 +190,7 @@ export function buildMeasurementsChartsSection(
|
||||
tableBottomMargin,
|
||||
ensureSpace,
|
||||
unitFor,
|
||||
vitalTypes,
|
||||
vitalGroups,
|
||||
} = context;
|
||||
let y = state.y;
|
||||
|
||||
@@ -177,37 +202,22 @@ export function buildMeasurementsChartsSection(
|
||||
doc.text(t("doctorReport.vitalsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const vitalRows: string[][] = [];
|
||||
|
||||
for (const type of vitalTypes) {
|
||||
const s = data.stats[type];
|
||||
if (!s) continue;
|
||||
const unit = unitFor(type);
|
||||
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
|
||||
// layer reconstructs them for exactly this row); the vitals unit is hours
|
||||
// (`h`), so convert to keep value and unit consistent — the same hours
|
||||
// value the FHIR sleep Observation emits.
|
||||
const conv =
|
||||
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
|
||||
vitalRows.push([
|
||||
t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
|
||||
`${num(conv(s.latest))} ${unit}`.trim(),
|
||||
`${num(conv(s.avg))} ${unit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
// One sub-headed block per selection group, in the same order the panel
|
||||
// shows them, carrying every selected leaf that has data. The aggregator has
|
||||
// already applied the selection, so a leaf missing from `data.stats` is a
|
||||
// leaf that was withheld or has no reading — the table cannot over-serve.
|
||||
const vitalRows: VitalTableRow[] = [];
|
||||
|
||||
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
|
||||
// the user's display unit. Reference ranges come from the server-side
|
||||
// `getEffectiveRange()` (already baked into `data.glucoseRanges`).
|
||||
// the user's display unit. They belong under the glucose group heading with
|
||||
// the raw series, not appended after the whole table.
|
||||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||||
const glucoseContextRows: string[][] = [];
|
||||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||||
const s = data.glucoseStats?.[ctx];
|
||||
if (!s) continue;
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
vitalRows.push([
|
||||
glucoseContextRows.push([
|
||||
t(GLUCOSE_LABEL_KEYS[ctx]),
|
||||
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
|
||||
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
|
||||
@@ -217,6 +227,33 @@ export function buildMeasurementsChartsSection(
|
||||
]);
|
||||
}
|
||||
|
||||
for (const group of vitalGroups) {
|
||||
const groupRows: string[][] = [];
|
||||
for (const type of group.types) {
|
||||
const s = data.stats[type];
|
||||
if (!s) continue;
|
||||
const unit = unitFor(type);
|
||||
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
|
||||
// layer reconstructs them for exactly this row); the vitals unit is
|
||||
// hours (`h`), so convert to keep value and unit consistent — the same
|
||||
// hours value the FHIR sleep Observation emits.
|
||||
const conv =
|
||||
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
|
||||
groupRows.push([
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
`${num(conv(s.latest))} ${unit}`.trim(),
|
||||
`${num(conv(s.avg))} ${unit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
if (group.id === "glucose") groupRows.push(...glucoseContextRows);
|
||||
if (groupRows.length === 0) continue;
|
||||
vitalRows.push(groupSubHeadRow(t(group.labelKey)));
|
||||
vitalRows.push(...groupRows);
|
||||
}
|
||||
|
||||
if (vitalRows.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
@@ -394,7 +431,9 @@ export function buildMeasurementsChartsSection(
|
||||
// A chart never tears across a page boundary — break before drawing
|
||||
// it whole.
|
||||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||||
const label = t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? "");
|
||||
const label = t(
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? "",
|
||||
);
|
||||
y = drawSparkline(doc, {
|
||||
x: margin,
|
||||
y,
|
||||
|
||||
@@ -27,14 +27,22 @@ export interface DoctorReportPdfRenderContext {
|
||||
now: Date;
|
||||
insuranceNumber: string | null;
|
||||
includeCharts: boolean;
|
||||
aiSummary: string | null;
|
||||
footerTz: string;
|
||||
margin: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
contentMaxY: number;
|
||||
tableBottomMargin: number;
|
||||
vitalTypes: readonly string[];
|
||||
/**
|
||||
* The measurement table's groups, in selection order. Each carries the
|
||||
* group's label key and its measurement types; a group whose types produced
|
||||
* no rows is skipped at render time.
|
||||
*/
|
||||
vitalGroups: ReadonlyArray<{
|
||||
id: string;
|
||||
labelKey: string;
|
||||
types: readonly string[];
|
||||
}>;
|
||||
unitFor: (type: string) => string;
|
||||
ensureSpace: (current: number, needed: number) => number;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import type { GlucoseUnit } from "@/lib/glucose";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import type {
|
||||
ComplianceSchedule,
|
||||
@@ -344,26 +343,17 @@ export interface DoctorReportRange {
|
||||
export interface CollectDoctorReportOptions {
|
||||
/** Free-text practice/clinic name to print on the cover. Optional. */
|
||||
practiceName?: string | null;
|
||||
/**
|
||||
* v1.4.25 W6c — per-section visibility toggles. When omitted the
|
||||
* documented defaults apply (every section ON except mood). When
|
||||
* `sections.mood = false` the aggregator does NOT query
|
||||
* `MoodEntry` at all — the mood data never leaves the DB row
|
||||
* (privacy-by-default). Other sections are stripped from the
|
||||
* returned payload so downstream renderers (PDF + JSON consumer)
|
||||
* don't accidentally print a section the user opted out of.
|
||||
*/
|
||||
sections?: Partial<DoctorReportPrefs>;
|
||||
/**
|
||||
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
|
||||
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
|
||||
* module forces its report section / FHIR resources out of the payload,
|
||||
* regardless of the user's per-report `sections` toggles: a user who turned
|
||||
* off the Sleep module exports no sleep series, mood Observations, glucose
|
||||
* panel, recovery/strain scores, cycle summary, or lab results for the
|
||||
* modules they no longer keep. Core clinical sections (weight / BP / pulse /
|
||||
* medications) carry no module key and are always included. Injectable for
|
||||
* tests so the module ↔ section mapping can be asserted without a DB.
|
||||
* module forces its leaves out of the payload regardless of the selection: a
|
||||
* user who turned off the Sleep module exports no sleep series, whatever the
|
||||
* report asked for. Injectable for tests so the module ↔ leaf mapping can be
|
||||
* asserted without a DB.
|
||||
*
|
||||
* The report SCOPE is not an option — it is the required third parameter of
|
||||
* `collectDoctorReportData`, so a new egress path cannot be written without
|
||||
* naming where its selection came from.
|
||||
*/
|
||||
moduleMap?: Record<ModuleKey, boolean>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 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("the derived figures ride their own leaf", () => {
|
||||
it("withholds the BMI figure while the weight series stays", async () => {
|
||||
// The sweep alone cannot isolate this: BODY_MASS_INDEX also gates its own
|
||||
// measurement rows, so the payload differs either way. The figure needs
|
||||
// its own assertion, because weight rows stay readable for exactly this
|
||||
// computation and it would be easy to leave the result ungated.
|
||||
const withBmi = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["WEIGHT", "BODY_MASS_INDEX"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(withBmi.bmi).not.toBeNull();
|
||||
|
||||
const withoutBmi = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["WEIGHT"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(withoutBmi.bmi).toBeNull();
|
||||
expect(withoutBmi.stats.WEIGHT).toBeDefined();
|
||||
});
|
||||
|
||||
it("computes the BMI figure without carrying the weight series", async () => {
|
||||
// The other half: choosing the figure and not the series still needs the
|
||||
// weight rows read, and must not smuggle the series out with them.
|
||||
const payload = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["BODY_MASS_INDEX"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(payload.bmi).not.toBeNull();
|
||||
expect(payload.stats.WEIGHT).toBeUndefined();
|
||||
expect(payload.measurements.WEIGHT).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withholds the GLP-1 block while its inputs stay readable", async () => {
|
||||
const on = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["GLP1_THERAPY"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(on.glp1).not.toBeNull();
|
||||
|
||||
const off = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["MEDICATION_LIST", "MEDICATION_COMPLIANCE"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(off.glp1).toBeNull();
|
||||
expect(off.medications.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("withholds the glucose panel while the raw series stays", async () => {
|
||||
const withPanel = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["BLOOD_GLUCOSE", "GLUCOSE_PANEL"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(Object.keys(withPanel.glucoseStats).length).toBeGreaterThan(0);
|
||||
|
||||
const seriesOnly = await collectDoctorReportData(
|
||||
"u1",
|
||||
RANGE,
|
||||
selectionFromLeaves(["BLOOD_GLUCOSE"]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(seriesOnly.glucoseStats).toEqual({});
|
||||
expect(seriesOnly.glucoseClinical.readingCount).toBe(0);
|
||||
expect(seriesOnly.stats.BLOOD_GLUCOSE).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-closed exclusion", () => {
|
||||
it("excludes a measurement type the catalogue does not know", () => {
|
||||
const gate = resolveReportGate(
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* The structured clinical records on the doctor report: labs, illness
|
||||
* episodes, allergies and family history.
|
||||
*
|
||||
* Each one is its own leaf and its own read. A refused leaf is not queried at
|
||||
* all — the payload reflects "this was never fetched", not "this was fetched
|
||||
* and then dropped". No free-text note column is ever selected on this path;
|
||||
* the only decrypted free text is the allergy reaction, which is what an
|
||||
* allergy record is for.
|
||||
*
|
||||
* Split out of the aggregator with the selection rework.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-types";
|
||||
|
||||
/**
|
||||
* Structured lab results over the window, reduced to the latest reading per
|
||||
* analyte with a count, so the report is a concise panel rather than a raw
|
||||
* dump. Analyte keys fold case-insensitively.
|
||||
*/
|
||||
export async function loadLabResults(
|
||||
userId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportData["labResults"]> {
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
|
||||
orderBy: { takenAt: "asc" },
|
||||
select: {
|
||||
panel: true,
|
||||
analyte: true,
|
||||
value: true,
|
||||
valueText: true,
|
||||
unit: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
takenAt: true,
|
||||
},
|
||||
});
|
||||
const byAnalyte = new Map<
|
||||
string,
|
||||
NonNullable<DoctorReportData["labResults"]>[number]
|
||||
>();
|
||||
for (const r of rows) {
|
||||
const key = r.analyte.toLowerCase();
|
||||
const prev = byAnalyte.get(key);
|
||||
byAnalyte.set(key, {
|
||||
panel: r.panel,
|
||||
analyte: r.analyte,
|
||||
value: r.value,
|
||||
valueText: r.valueText,
|
||||
unit: r.unit,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
takenAt: r.takenAt.toISOString(),
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
const collapsed = Array.from(byAnalyte.values());
|
||||
return collapsed.length > 0 ? collapsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Illness / condition episodes overlapping the window. An episode overlaps
|
||||
* when it began on or before the window end AND is either still ongoing or
|
||||
* resolved on or after the window start. Labels, lifecycle and dates only.
|
||||
*/
|
||||
export async function loadIllnessEpisodes(
|
||||
userId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportData["illnessEpisodes"]> {
|
||||
const rows = await prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
onsetAt: { lte: end },
|
||||
OR: [{ resolvedAt: null }, { resolvedAt: { gte: start } }],
|
||||
},
|
||||
orderBy: { onsetAt: "asc" },
|
||||
select: {
|
||||
label: true,
|
||||
type: true,
|
||||
lifecycle: true,
|
||||
onsetAt: true,
|
||||
resolvedAt: true,
|
||||
},
|
||||
});
|
||||
const mapped = rows.map((e) => ({
|
||||
label: e.label,
|
||||
type: e.type,
|
||||
lifecycle: e.lifecycle,
|
||||
onsetAt: e.onsetAt.toISOString(),
|
||||
resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
|
||||
}));
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured allergy records. Reference data, not time-windowed — a
|
||||
* penicillin allergy does not expire with the report window.
|
||||
*
|
||||
* The reaction description decrypts fail-soft per row. A reaction that WAS
|
||||
* recorded but cannot be read is flagged rather than silently blanked, so the
|
||||
* renderer prints an honest marker instead of a clinician-facing export
|
||||
* reading as "no reaction recorded".
|
||||
*/
|
||||
export async function loadAllergies(
|
||||
userId: string,
|
||||
): Promise<DoctorReportData["allergies"]> {
|
||||
const rows = await prisma.allergy.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
substance: true,
|
||||
category: true,
|
||||
type: true,
|
||||
severity: true,
|
||||
status: true,
|
||||
reactionEncrypted: true,
|
||||
},
|
||||
});
|
||||
const mapped = rows.map((r) => {
|
||||
const { reaction, reactionUnreadable } = decryptAllergyReaction(
|
||||
r.reactionEncrypted,
|
||||
);
|
||||
if (reactionUnreadable) {
|
||||
getEvent()?.addWarning(
|
||||
`doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
substance: r.substance,
|
||||
category: r.category,
|
||||
type: r.type,
|
||||
severity: r.severity,
|
||||
status: r.status,
|
||||
reaction,
|
||||
reactionUnreadable,
|
||||
};
|
||||
});
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/** Structured family history. Same stance as allergies: reference data. */
|
||||
export async function loadFamilyHistory(
|
||||
userId: string,
|
||||
): Promise<DoctorReportData["familyHistory"]> {
|
||||
const rows = await prisma.familyHistoryEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { relationship: true, condition: true, ageAtOnset: true },
|
||||
});
|
||||
return rows.length > 0 ? rows : null;
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Server-side aggregator for doctor-report data.
|
||||
*
|
||||
* Single source of truth for the payload every output path reads: the PDF, the
|
||||
* FHIR document bundle, the package zip, the clinician share view, the FHIR
|
||||
* REST face and the MCP doctor-visit surfaces. A leaf excluded here is absent
|
||||
* from `measurements`, from `stats` and from the structured blocks, so every
|
||||
* renderer describes the same record by construction rather than by each one
|
||||
* remembering to apply the same filter.
|
||||
*
|
||||
* The third parameter is a REQUIRED, brand-typed {@link ReportSelection}. It
|
||||
* used to be optional and default to everything-on, which meant a new egress
|
||||
* path could be written without anyone answering "whose selection?". Now it
|
||||
* cannot compile without an answer.
|
||||
*
|
||||
* Pure data assembly — no auth, no rate limit, no audit. Idempotent.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
|
||||
import { MEASUREMENT_LEAF_IDS } from "@/lib/report-selection/catalogue";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
import {
|
||||
WELLNESS_SCORE_REPORT_TYPES,
|
||||
type CollectDoctorReportOptions,
|
||||
type DoctorReportData,
|
||||
type DoctorReportMood,
|
||||
type DoctorReportRange,
|
||||
type DoctorReportStats,
|
||||
} from "@/lib/doctor-report-types";
|
||||
import {
|
||||
buildLedgerCompliance,
|
||||
collapseMeasurementsToCanonical,
|
||||
minMaxOf,
|
||||
sanitisePracticeName,
|
||||
summariseCanonicalRecovery,
|
||||
} from "@/lib/doctor-report-helpers";
|
||||
import {
|
||||
excludedMeasurementTypes,
|
||||
filterMeasurementKeys,
|
||||
resolveReportGate,
|
||||
} from "./selection-gate";
|
||||
import { loadDenseMeasurementBuckets } from "./dense-buckets";
|
||||
import { summariseDenseBuckets } from "./measurement-series";
|
||||
import { emptyGlucoseClinical } from "./glucose-panel";
|
||||
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
|
||||
import {
|
||||
loadAllergies,
|
||||
loadFamilyHistory,
|
||||
loadIllnessEpisodes,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
|
||||
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
|
||||
/** No medication row is read unless at least one medication leaf is admitted. */
|
||||
const MEDICATION_LEAVES = [
|
||||
"MEDICATION_LIST",
|
||||
"MEDICATION_ADMINISTRATIONS",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"GLP1_THERAPY",
|
||||
] as const;
|
||||
|
||||
export async function collectDoctorReportData(
|
||||
userId: string,
|
||||
range: DoctorReportRange,
|
||||
selection: ReportSelection,
|
||||
options: CollectDoctorReportOptions = {},
|
||||
): Promise<DoctorReportData> {
|
||||
const { start, end, days } = range;
|
||||
|
||||
// Resolve the per-user module map once. A disabled module wins over a
|
||||
// selected leaf; a deselected leaf wins over an enabled module. Injectable
|
||||
// for tests.
|
||||
const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId));
|
||||
|
||||
// v1.30.22 — fail-closed backstop on the aggregate's OWN module key.
|
||||
//
|
||||
// Everything below gates individual leaves, but nothing ever gated
|
||||
// `doctorReport` itself — the key that decides whether the whole-record
|
||||
// aggregate may be assembled at all. REFUSE, not omit: this is the
|
||||
// whole-record aggregate, so there is no honest partial answer. It throws
|
||||
// rather than returning an envelope because reaching here means a caller is
|
||||
// missing its gate — a bug to surface, not a flow to serve. Callers that can
|
||||
// degrade gracefully gate themselves BEFORE calling.
|
||||
if (moduleMap.doctorReport === false) {
|
||||
throw new Error(
|
||||
'collectDoctorReportData called with the "doctorReport" module ' +
|
||||
"disabled — the calling surface is missing its module gate",
|
||||
);
|
||||
}
|
||||
|
||||
const gate = resolveReportGate(selection, moduleMap);
|
||||
|
||||
// Types read BEFORE the output filter because something else is derived from
|
||||
// them. Weight feeds the BMI figure and the GLP-1 weight delta; the raw
|
||||
// glucose series feeds the clinical panel. Each derived block is gated by its
|
||||
// own leaf, so keeping the rows here widens nothing that leaves the function.
|
||||
const keepForDerived = new Set<MeasurementType>();
|
||||
if (gate.admits("BODY_MASS_INDEX") || gate.admits("GLP1_THERAPY")) {
|
||||
keepForDerived.add("WEIGHT");
|
||||
}
|
||||
if (gate.admits("GLUCOSE_PANEL")) keepForDerived.add("BLOOD_GLUCOSE");
|
||||
|
||||
const excluded = excludedMeasurementTypes(
|
||||
gate,
|
||||
MEASUREMENT_LEAF_IDS,
|
||||
keepForDerived,
|
||||
);
|
||||
|
||||
const userProfile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
username: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
timezone: true,
|
||||
sourcePriorityJson: true,
|
||||
fullName: true,
|
||||
insurerName: true,
|
||||
insurerIkNumber: true,
|
||||
},
|
||||
});
|
||||
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
|
||||
|
||||
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
|
||||
const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
|
||||
const denseGlucose =
|
||||
aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
|
||||
const rawExcluded = [...excluded];
|
||||
if (densePulse) rawExcluded.push("PULSE");
|
||||
if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE");
|
||||
|
||||
const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf));
|
||||
const wantsIntakeEvents =
|
||||
gate.admits("MEDICATION_ADMINISTRATIONS") ||
|
||||
gate.admits("MEDICATION_COMPLIANCE") ||
|
||||
gate.admits("GLP1_THERAPY");
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, denseBuckets] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
// Soft-deleted rows never reach the report.
|
||||
where: {
|
||||
userId,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
deletedAt: null,
|
||||
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Narrow select: the collector reads exactly these seven fields.
|
||||
// The full-width read pulled every column — including the encrypted
|
||||
// notes — across up to 730 days of rows.
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
sleepStage: true,
|
||||
glucoseContext: true,
|
||||
},
|
||||
}),
|
||||
wantsMedications
|
||||
? prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: {
|
||||
schedules: {
|
||||
select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true },
|
||||
},
|
||||
scheduleRevisions: {
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
payload: true,
|
||||
supersededByRevisionId: true,
|
||||
},
|
||||
},
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 1,
|
||||
select: { takenAt: true, injectionSite: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
wantsIntakeEvents
|
||||
? prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: start, lte: end },
|
||||
},
|
||||
include: {
|
||||
medication: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
dose: true,
|
||||
atcCode: true,
|
||||
rxNormCode: true,
|
||||
deliveryForm: true,
|
||||
asNeeded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// Zero DB read when the mood leaf was not chosen: the payload reflects
|
||||
// "mood was never fetched", not "mood was fetched and then dropped".
|
||||
gate.admits("MOOD")
|
||||
? prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
// Two fields: the score (summary + distribution) and the tags
|
||||
// (GLP-1 side-effect tallies). Never the note columns.
|
||||
select: { score: true, tags: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadDenseMeasurementBuckets({
|
||||
userId,
|
||||
start,
|
||||
end,
|
||||
reportTz,
|
||||
densePulse,
|
||||
denseGlucose,
|
||||
}),
|
||||
]);
|
||||
|
||||
const denseSummary = summariseDenseBuckets(
|
||||
denseBuckets,
|
||||
reportTz,
|
||||
userProfile?.sourcePriorityJson ?? null,
|
||||
days,
|
||||
);
|
||||
|
||||
// Collapse each multi-source metric to its CANONICAL source before grouping,
|
||||
// so the per-type avg/min/max match the dashboard rather than blending
|
||||
// overlapping sources.
|
||||
const canonicalMeasurements = collapseMeasurementsToCanonical(
|
||||
measurements,
|
||||
reportTz,
|
||||
userProfile?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
const byType: Record<
|
||||
string,
|
||||
Array<{ value: number; measuredAt: string }>
|
||||
> = {};
|
||||
for (const m of canonicalMeasurements) {
|
||||
if (!byType[m.type]) byType[m.type] = [];
|
||||
byType[m.type].push({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
for (const [type, entries] of Object.entries(denseSummary.byType)) {
|
||||
byType[type] = entries;
|
||||
}
|
||||
for (const entries of Object.values(byType)) {
|
||||
entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
|
||||
}
|
||||
|
||||
// SLEEP_DURATION enters `byType` as RAW per-stage rows. Every other sleep
|
||||
// surface shows the per-night reconstructed asleep total, so route the
|
||||
// report's sleep value through the same engine — one number, one surface.
|
||||
const sleepRows = measurements.filter(
|
||||
(m) => m.type === "SLEEP_DURATION",
|
||||
) as unknown as SleepStageRow[];
|
||||
if (sleepRows.length > 0) {
|
||||
const nights = reconstructSleepNights(
|
||||
sleepRows,
|
||||
reportTz,
|
||||
userProfile?.sourcePriorityJson ?? null,
|
||||
).filter((n) => n.asleepMinutes > 0);
|
||||
if (nights.length > 0) {
|
||||
byType.SLEEP_DURATION = nights.map((n) => ({
|
||||
value: n.asleepMinutes,
|
||||
measuredAt: n.measuredAt.toISOString(),
|
||||
}));
|
||||
} else {
|
||||
delete byType.SLEEP_DURATION;
|
||||
}
|
||||
}
|
||||
|
||||
const stats: Record<string, DoctorReportStats> = {};
|
||||
for (const [type, entries] of Object.entries(byType)) {
|
||||
const values = entries.map((e) => e.value);
|
||||
stats[type] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
...minMaxOf(values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
Object.assign(stats, denseSummary.stats);
|
||||
|
||||
// Medication compliance through the dose-ledger authority — the same engine
|
||||
// the detail page uses, not a raw-row tally. As-needed medications are
|
||||
// excluded: no schedule, no expected dose, no fabricated 100 % on a
|
||||
// clinical report. The medication itself stays on the list.
|
||||
const compliance = buildLedgerCompliance(
|
||||
medications.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
asNeeded: m.asNeeded,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
schedules: m.schedules,
|
||||
scheduleRevisions: m.scheduleRevisions,
|
||||
pauseEras: m.pauseEras,
|
||||
})),
|
||||
intakeEvents.map((e) => ({
|
||||
medicationId: e.medicationId,
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
attributionSource: e.attributionSource ?? undefined,
|
||||
})),
|
||||
reportTz,
|
||||
start,
|
||||
end,
|
||||
end,
|
||||
);
|
||||
|
||||
const ledger = buildAdministrationLedger(medications, intakeEvents);
|
||||
|
||||
const moodScores = moodEntries.map((e) => e.score);
|
||||
const mood: DoctorReportMood | null =
|
||||
moodScores.length > 0
|
||||
? {
|
||||
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
|
||||
min: minMaxOf(moodScores).min,
|
||||
max: minMaxOf(moodScores).max,
|
||||
count: moodScores.length,
|
||||
distribution: {
|
||||
1: moodScores.filter((s) => s === 1).length,
|
||||
2: moodScores.filter((s) => s === 2).length,
|
||||
3: moodScores.filter((s) => s === 3).length,
|
||||
4: moodScores.filter((s) => s === 4).length,
|
||||
5: moodScores.filter((s) => s === 5).length,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
// BMI from the latest weight and the profile height. Gated by its own leaf,
|
||||
// which is also the leaf that gates any recorded BODY_MASS_INDEX rows — one
|
||||
// control for the figure and the series alike.
|
||||
const weightStats = stats.WEIGHT;
|
||||
const bmiRaw =
|
||||
weightStats && userProfile?.heightCm
|
||||
? weightStats.latest / (userProfile.heightCm / 100) ** 2
|
||||
: null;
|
||||
const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
|
||||
|
||||
// Per-context glucose stats + effective ranges (canonical mg/dL). The whole
|
||||
// panel rides the GLUCOSE_PANEL leaf: stats, ranges and clinical metrics
|
||||
// collapse together, so the artefact never carries half of it.
|
||||
const glucosePanelOn = gate.admits("GLUCOSE_PANEL");
|
||||
const glucoseStats: Record<string, DoctorReportStats> =
|
||||
glucosePanelOn && denseGlucose ? { ...denseSummary.glucoseStats } : {};
|
||||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||||
const glucoseRows = glucosePanelOn
|
||||
? measurements.filter((m) => m.type === "BLOOD_GLUCOSE")
|
||||
: [];
|
||||
const overrides = (userProfile?.thresholdsJson ??
|
||||
null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
};
|
||||
if (glucosePanelOn) {
|
||||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||||
if (!denseGlucose) {
|
||||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||||
if (rows.length === 0) continue;
|
||||
const values = rows.map((r) => r.value);
|
||||
glucoseStats[ctx] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
...minMaxOf(values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
} else if (!glucoseStats[ctx]) {
|
||||
continue;
|
||||
}
|
||||
const eff = getEffectiveRange(
|
||||
thresholdMetricForContext(ctx),
|
||||
profileForRange,
|
||||
overrides,
|
||||
);
|
||||
if (eff.range) {
|
||||
glucoseRanges[ctx] = {
|
||||
min: eff.range.greenMin,
|
||||
max: eff.range.greenMax,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The clinical panel over the whole report period, all contexts pooled.
|
||||
// `windowDays` is the report's own period so TIR / GMI / eA1C / CV% reflect
|
||||
// exactly the readings the rest of the report tabulates.
|
||||
const glucoseClinical = !glucosePanelOn
|
||||
? emptyGlucoseClinical(days)
|
||||
: denseGlucose
|
||||
? denseSummary.glucoseClinical
|
||||
: computeGlucoseClinicalMetrics(
|
||||
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
|
||||
{ windowDays: days, now: end },
|
||||
);
|
||||
|
||||
const practiceName = sanitisePracticeName(options.practiceName);
|
||||
|
||||
// The output half of the query gate, reading the same gate object.
|
||||
const filteredByType = filterMeasurementKeys(byType, gate);
|
||||
const filteredStats = filterMeasurementKeys(stats, gate);
|
||||
|
||||
const glp1 = gate.admits("GLP1_THERAPY")
|
||||
? buildGlp1Block({
|
||||
medications,
|
||||
compliance,
|
||||
weightSeries: byType.WEIGHT ?? [],
|
||||
moodTagRows: moodEntries,
|
||||
})
|
||||
: null;
|
||||
|
||||
// The wellness summary is filtered to the same score leaves the stats carry,
|
||||
// so it cannot disagree with them. RECOVERY_SCORE carries both the native row
|
||||
// and the computed proxy for one day; resolve to the canonical row so the PDF
|
||||
// reads the SAME value the tile shows.
|
||||
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
|
||||
if (!gate.admits(type)) return [];
|
||||
if (type === "RECOVERY_SCORE") {
|
||||
const summary = summariseCanonicalRecovery(measurements, reportTz);
|
||||
return summary ? [summary] : [];
|
||||
}
|
||||
const s = stats[type];
|
||||
const rows = byType[type];
|
||||
if (!s || !rows || rows.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
type,
|
||||
latest: Math.round(s.latest),
|
||||
avg: Math.round(s.avg),
|
||||
min: Math.round(s.min),
|
||||
max: Math.round(s.max),
|
||||
count: s.count,
|
||||
latestAt: rows[rows.length - 1].measuredAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
const wellnessScores =
|
||||
wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
|
||||
|
||||
// Each remaining structured block: read only when its leaf was chosen.
|
||||
const [cycle, labResults, illnessEpisodes, allergies, familyHistory] =
|
||||
await Promise.all([
|
||||
gate.admits("CYCLE")
|
||||
? buildCycleExportSummary(userId, end.toISOString().slice(0, 10))
|
||||
: Promise.resolve(null),
|
||||
gate.admits("LAB_RESULTS")
|
||||
? loadLabResults(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("ILLNESS_EPISODES")
|
||||
? loadIllnessEpisodes(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
|
||||
gate.admits("FAMILY_HISTORY")
|
||||
? loadFamilyHistory(userId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const identityOn = gate.admits("PATIENT_IDENTITY");
|
||||
const insuranceOn = gate.admits("INSURANCE");
|
||||
|
||||
return {
|
||||
period: {
|
||||
days,
|
||||
// `since` mirrors `start`, preserved for in-flight clients.
|
||||
since: start.toISOString(),
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
},
|
||||
patient: {
|
||||
username: identityOn ? (userProfile?.username ?? null) : null,
|
||||
dateOfBirth:
|
||||
identityOn && userProfile?.dateOfBirth
|
||||
? userProfile.dateOfBirth.toISOString()
|
||||
: null,
|
||||
gender: identityOn ? (userProfile?.gender ?? null) : null,
|
||||
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
|
||||
fullName: identityOn ? (userProfile?.fullName ?? null) : null,
|
||||
insurerName: insuranceOn ? (userProfile?.insurerName ?? null) : null,
|
||||
insurerIkNumber: insuranceOn
|
||||
? (userProfile?.insurerIkNumber ?? null)
|
||||
: null,
|
||||
},
|
||||
practiceName,
|
||||
measurements: filteredByType,
|
||||
stats: filteredStats,
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseClinical,
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
|
||||
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
|
||||
medications: gate.admits("MEDICATION_LIST")
|
||||
? medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
atcCode: m.atcCode,
|
||||
rxNormCode: m.rxNormCode,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
})),
|
||||
}))
|
||||
: [],
|
||||
medicationAdministrations: gate.admits("MEDICATION_ADMINISTRATIONS")
|
||||
? ledger.administrations
|
||||
: [],
|
||||
medicationAdministrationsTruncation: gate.admits(
|
||||
"MEDICATION_ADMINISTRATIONS",
|
||||
)
|
||||
? ledger.truncation
|
||||
: null,
|
||||
mood,
|
||||
glp1,
|
||||
wellnessScores,
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
allergies,
|
||||
familyHistory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* The per-local-day pre-aggregation for the two sample-dense measurement
|
||||
* types.
|
||||
*
|
||||
* On a long window a CGM or a per-sample heart-rate account produces six
|
||||
* figures of rows, so pulse and glucose collapse to one row per local
|
||||
* day/source/device/context in SQL and the canonical-source picker then works
|
||||
* over that bounded intermediate set. Every other type keeps the raw-row path.
|
||||
*
|
||||
* Extracted from the aggregator with the selection rework: the query is a job
|
||||
* of its own, it is the one raw-SQL surface on this path, and keeping it in a
|
||||
* thousand-line function is how a parameter stops being reviewed.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** One pre-aggregated bucket: a local day for one type/source/device/context. */
|
||||
export interface DenseMeasurementBucket {
|
||||
bucketStart: Date;
|
||||
type: MeasurementType;
|
||||
source: MeasurementSource;
|
||||
deviceType: string | null;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
count: number;
|
||||
sumValue: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
latestAt: Date;
|
||||
latestValue: number;
|
||||
clinicalCount: number;
|
||||
clinicalSum: number | null;
|
||||
clinicalSumSquares: number | null;
|
||||
clinicalFirstAt: Date | null;
|
||||
clinicalLastAt: Date | null;
|
||||
clinicalTirCount: number;
|
||||
clinicalTbr1Count: number;
|
||||
clinicalTbr2Count: number;
|
||||
clinicalTar1Count: number;
|
||||
clinicalTar2Count: number;
|
||||
clinicalLowRiskSum: number | null;
|
||||
clinicalHighRiskSum: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the dense buckets for whichever of the two types the caller wants. Both
|
||||
* flags false is not a call: the caller skips it entirely.
|
||||
*
|
||||
* Every value is bound as a tagged-template parameter — no splice, no
|
||||
* interpolation of anything that came off the wire.
|
||||
*/
|
||||
export async function loadDenseMeasurementBuckets(params: {
|
||||
userId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
reportTz: string;
|
||||
densePulse: boolean;
|
||||
denseGlucose: boolean;
|
||||
}): Promise<DenseMeasurementBucket[]> {
|
||||
const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
|
||||
if (!densePulse && !denseGlucose) return [];
|
||||
return prisma.$queryRaw<DenseMeasurementBucket[]>`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."id",
|
||||
m."type",
|
||||
m."source",
|
||||
m."device_type",
|
||||
m."glucose_context",
|
||||
m."value",
|
||||
m."measured_at",
|
||||
date_trunc(
|
||||
'day',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
|
||||
) AS local_day
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${start}
|
||||
AND m."measured_at" <= ${end}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND (
|
||||
(${densePulse} AND m."type" = 'PULSE'::"measurement_type")
|
||||
OR
|
||||
(${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
|
||||
)
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
localized.*,
|
||||
CASE
|
||||
WHEN "value" > 0
|
||||
THEN 1.509 * (power(ln("value"), 1.084) - 5.381)
|
||||
ELSE NULL
|
||||
END AS risk_transform
|
||||
FROM localized
|
||||
),
|
||||
latest_rows AS (
|
||||
SELECT DISTINCT ON (
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
)
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
"measured_at" AS latest_at,
|
||||
"value" AS latest_value
|
||||
FROM localized
|
||||
ORDER BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
"measured_at" DESC,
|
||||
"id" DESC
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
COUNT(*)::int AS sample_count,
|
||||
SUM("value")::double precision AS sum_value,
|
||||
MIN("value")::double precision AS min_value,
|
||||
MAX("value")::double precision AS max_value,
|
||||
COUNT(*) FILTER (WHERE "value" > 0)::int AS clinical_count,
|
||||
SUM("value") FILTER (WHERE "value" > 0)::double precision
|
||||
AS clinical_sum,
|
||||
SUM("value" * "value") FILTER (WHERE "value" > 0)::double precision
|
||||
AS clinical_sum_squares,
|
||||
MIN("measured_at") FILTER (WHERE "value" > 0) AS clinical_first_at,
|
||||
MAX("measured_at") FILTER (WHERE "value" > 0) AS clinical_last_at,
|
||||
COUNT(*) FILTER (WHERE "value" BETWEEN 70 AND 180)::int
|
||||
AS clinical_tir_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 70)::int
|
||||
AS clinical_tbr1_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 54)::int
|
||||
AS clinical_tbr2_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 180)::int
|
||||
AS clinical_tar1_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 250)::int
|
||||
AS clinical_tar2_count,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN risk_transform < 0
|
||||
THEN 10 * risk_transform * risk_transform
|
||||
ELSE 0
|
||||
END
|
||||
) FILTER (WHERE risk_transform IS NOT NULL)::double precision
|
||||
AS clinical_low_risk_sum,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN risk_transform > 0
|
||||
THEN 10 * risk_transform * risk_transform
|
||||
ELSE 0
|
||||
END
|
||||
) FILTER (WHERE risk_transform IS NOT NULL)::double precision
|
||||
AS clinical_high_risk_sum
|
||||
FROM scored
|
||||
GROUP BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
)
|
||||
SELECT
|
||||
grouped.local_day AT TIME ZONE ${reportTz} AS "bucketStart",
|
||||
grouped."type",
|
||||
grouped."source",
|
||||
grouped."device_type" AS "deviceType",
|
||||
grouped."glucose_context" AS "glucoseContext",
|
||||
grouped.sample_count AS "count",
|
||||
grouped.sum_value AS "sumValue",
|
||||
grouped.min_value AS "minValue",
|
||||
grouped.max_value AS "maxValue",
|
||||
latest_rows.latest_at AS "latestAt",
|
||||
latest_rows.latest_value::double precision AS "latestValue",
|
||||
grouped.clinical_count AS "clinicalCount",
|
||||
grouped.clinical_sum AS "clinicalSum",
|
||||
grouped.clinical_sum_squares AS "clinicalSumSquares",
|
||||
grouped.clinical_first_at AS "clinicalFirstAt",
|
||||
grouped.clinical_last_at AS "clinicalLastAt",
|
||||
grouped.clinical_tir_count AS "clinicalTirCount",
|
||||
grouped.clinical_tbr1_count AS "clinicalTbr1Count",
|
||||
grouped.clinical_tbr2_count AS "clinicalTbr2Count",
|
||||
grouped.clinical_tar1_count AS "clinicalTar1Count",
|
||||
grouped.clinical_tar2_count AS "clinicalTar2Count",
|
||||
grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
|
||||
grouped.clinical_high_risk_sum AS "clinicalHighRiskSum"
|
||||
FROM grouped
|
||||
INNER JOIN latest_rows
|
||||
ON latest_rows.local_day = grouped.local_day
|
||||
AND latest_rows."type" = grouped."type"
|
||||
AND latest_rows."source" = grouped."source"
|
||||
AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
ORDER BY "bucketStart" ASC, grouped."type" ASC
|
||||
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* The glucose clinical panel derived from pre-aggregated buckets.
|
||||
*
|
||||
* TIR / GMI / eA1C / CV% over the whole report period, computed by the same
|
||||
* literature-locked engine the insights panel and the coach consume. The
|
||||
* learning gate keeps a thin period from asserting a clinical picture off spot
|
||||
* data: too few readings, or too short a span, and the panel says so rather
|
||||
* than producing a number that reads as authoritative.
|
||||
*
|
||||
* Split out of the aggregator with the selection rework — it is a self-
|
||||
* contained numeric job with its own literature behind it.
|
||||
*/
|
||||
import {
|
||||
CV_INSTABILITY_THRESHOLD,
|
||||
estimatedA1c,
|
||||
gmi,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
import type { DenseMeasurementBucket } from "./dense-buckets";
|
||||
|
||||
export function emptyGlucoseClinical(
|
||||
windowDays: number,
|
||||
): GlucoseClinicalMetrics {
|
||||
return {
|
||||
stillLearning: true,
|
||||
|
||||
stillLearningReason: `No glucose readings in the last ${windowDays} days.`,
|
||||
windowDays,
|
||||
actualSpanDays: 0,
|
||||
readingCount: 0,
|
||||
meanMgdl: null,
|
||||
distribution: null,
|
||||
gmi: null,
|
||||
estimatedA1c: null,
|
||||
variability: null,
|
||||
advanced: null,
|
||||
isSpotEstimate: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function glucoseClinicalFromBuckets(
|
||||
buckets: readonly DenseMeasurementBucket[],
|
||||
windowDays: number,
|
||||
): GlucoseClinicalMetrics {
|
||||
let count = 0;
|
||||
let sum = 0;
|
||||
let sumSquares = 0;
|
||||
let firstAt: Date | null = null;
|
||||
let lastAt: Date | null = null;
|
||||
let tir = 0;
|
||||
let tbr1 = 0;
|
||||
let tbr2 = 0;
|
||||
let tar1 = 0;
|
||||
let tar2 = 0;
|
||||
let lowRisk = 0;
|
||||
let highRisk = 0;
|
||||
|
||||
for (const bucket of buckets) {
|
||||
if (bucket.type !== "BLOOD_GLUCOSE" || bucket.clinicalCount === 0) {
|
||||
continue;
|
||||
}
|
||||
count += bucket.clinicalCount;
|
||||
sum += bucket.clinicalSum ?? 0;
|
||||
sumSquares += bucket.clinicalSumSquares ?? 0;
|
||||
tir += bucket.clinicalTirCount;
|
||||
tbr1 += bucket.clinicalTbr1Count;
|
||||
tbr2 += bucket.clinicalTbr2Count;
|
||||
tar1 += bucket.clinicalTar1Count;
|
||||
tar2 += bucket.clinicalTar2Count;
|
||||
lowRisk += bucket.clinicalLowRiskSum ?? 0;
|
||||
highRisk += bucket.clinicalHighRiskSum ?? 0;
|
||||
if (
|
||||
bucket.clinicalFirstAt &&
|
||||
(!firstAt || bucket.clinicalFirstAt < firstAt)
|
||||
) {
|
||||
firstAt = bucket.clinicalFirstAt;
|
||||
}
|
||||
if (bucket.clinicalLastAt && (!lastAt || bucket.clinicalLastAt > lastAt)) {
|
||||
lastAt = bucket.clinicalLastAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0 || !firstAt || !lastAt) {
|
||||
return emptyGlucoseClinical(windowDays);
|
||||
}
|
||||
|
||||
const meanMgdl = sum / count;
|
||||
const actualSpanDays =
|
||||
(lastAt.getTime() - firstAt.getTime()) / (24 * 60 * 60 * 1000);
|
||||
const variance =
|
||||
count >= 2
|
||||
? Math.max(0, (sumSquares - (sum * sum) / count) / (count - 1))
|
||||
: null;
|
||||
const sd = variance === null ? null : Math.sqrt(variance);
|
||||
const cv = sd === null || meanMgdl === 0 ? null : (sd / meanMgdl) * 100;
|
||||
const fraction = (n: number) => n / count;
|
||||
const minutes = (n: number) => fraction(n) * 1440;
|
||||
const roundedSpan = Math.max(0, Math.round(actualSpanDays));
|
||||
let stillLearningReason: string | null = null;
|
||||
if (count < 14) {
|
||||
stillLearningReason = `Still learning — ${count} reading${
|
||||
count === 1 ? "" : "s"
|
||||
} over ${roundedSpan} day${
|
||||
roundedSpan === 1 ? "" : "s"
|
||||
}; at least 14 are needed for a meaningful estimate.`;
|
||||
} else if (actualSpanDays < 7) {
|
||||
stillLearningReason = `Still learning — readings span only ${roundedSpan} day${
|
||||
roundedSpan === 1 ? "" : "s"
|
||||
}; at least 7 days of coverage are needed.`;
|
||||
}
|
||||
|
||||
return {
|
||||
stillLearning: stillLearningReason !== null,
|
||||
stillLearningReason,
|
||||
windowDays,
|
||||
actualSpanDays,
|
||||
readingCount: count,
|
||||
meanMgdl,
|
||||
distribution: {
|
||||
tir: fraction(tir),
|
||||
tbrLevel1: fraction(tbr1),
|
||||
tbrLevel2: fraction(tbr2),
|
||||
tarLevel1: fraction(tar1),
|
||||
tarLevel2: fraction(tar2),
|
||||
minutesEquivalent: {
|
||||
tir: minutes(tir),
|
||||
tbrLevel1: minutes(tbr1),
|
||||
tbrLevel2: minutes(tbr2),
|
||||
tarLevel1: minutes(tar1),
|
||||
tarLevel2: minutes(tar2),
|
||||
},
|
||||
},
|
||||
gmi: gmi(meanMgdl),
|
||||
estimatedA1c: estimatedA1c(meanMgdl),
|
||||
variability:
|
||||
sd === null || cv === null
|
||||
? null
|
||||
: { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD },
|
||||
advanced: {
|
||||
jIndex: sd === null ? null : 0.001 * (meanMgdl + sd) ** 2,
|
||||
lbgi: lowRisk / count,
|
||||
hbgi: highRisk / count,
|
||||
},
|
||||
isSpotEstimate: count / Math.max(actualSpanDays, 1) < 24,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user