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:
Marc-André Bombeck authored and GitHub committed 2026-07-26 01:25:15 +02:00
commit 417288d3f5
159 files changed
+13080 -6444

No files matched your search

+66
View File
@@ -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
View File
@@ -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
View File
@@ -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 ({
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
});
+5
View File
@@ -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");
});
});
+141 -156
View File
@@ -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;
}
}
+50
View File
@@ -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);
},
);
+55
View File
@@ -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,
);
},
);
+15 -18
View File
@@ -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);
},
);
+16 -21
View File
@@ -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);
@@ -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,
);
});
+48
View File
@@ -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);
},
);
+18 -20
View File
@@ -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);
}
});
});
+78 -6
View File
@@ -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");
+1
View File
@@ -78,6 +78,7 @@ beforeEach(() => {
data: {} as never,
identity: { insuranceNumber: null },
germanAtc: false,
records: {},
});
});
+53 -24
View File
@@ -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(),
);
});
+24 -11
View File
@@ -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,
},
});
});
+147 -27
View File
@@ -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([]);
});
});
+80 -25
View File
@@ -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);
});
+46
View File
@@ -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
View File
@@ -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}
+51
View File
@@ -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",
},
});
},
);
+17 -6
View File
@@ -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
View File
@@ -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");
});
+51 -282
View File
@@ -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&#x27;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&#x27;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: {},
};
+1 -117
View File
@@ -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 && (
+73 -9
View File
@@ -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")}:
+13 -2
View File
@@ -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>
);
+7
View File
@@ -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();
+132
View File
@@ -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 };
}
+26
View File
@@ -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;
}
}
+7 -14
View File
@@ -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,
};
+14 -17
View File
@@ -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 };
}
/**
File diff suppressed because it is too large. Load diff
+36 -58
View File
@@ -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,
+10 -2
View File
@@ -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;
}
+8 -18
View File
@@ -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);
});
});
+157
View File
@@ -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;
}
+577
View File
@@ -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,
};
}
+211
View File
@@ -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
`;
}
+146
View File
@@ -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