diff --git a/prisma/migrations/0273_report_selection/migration.sql b/prisma/migrations/0273_report_selection/migration.sql
new file mode 100644
index 000000000..7325c56c2
--- /dev/null
+++ b/prisma/migrations/0273_report_selection/migration.sql
@@ -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();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2aad4be6a..90ca68d2b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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.
//
@@ -5378,18 +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")
- /// RETIRED. These two columns described a scoped FHIR face for a
- /// share link that was never built: no route ever served FHIR (or any
- /// machine-readable format) against an `hls_` token, so the create form's
- /// toggle, its resource-type picker, the list badge and the API description
- /// all promised reachability that did not exist. The controls are gone, the
- /// create path no longer writes anything but the defaults, and no read path
- /// touches either column — the values still on rows minted earlier are inert.
- /// Kept as columns so no data is destroyed to undo a promise.
- resourceTypes String[] @map("resource_types")
- 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
diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
deleted file mode 100644
index d993076dc..000000000
--- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-vi.mock("@/lib/db", () => ({
- prisma: {
- user: {
- findUnique: vi.fn(),
- update: vi.fn(),
- },
- },
- toJson: (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)(
- 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)(mkPut(body));
- expect(res.status).toBe(200);
- const env = (await res.json()) as { data: typeof body };
- // The canonical shape is the full prefs object: keys the body omits fill
- // from the defaults rather than being dropped.
- const canonical = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...body };
- expect(env.data).toEqual(canonical);
- expect(prisma.user.update).toHaveBeenCalledWith({
- where: { id: "user-1" },
- data: { doctorReportPrefsJson: canonical },
- });
- });
-
- // ── 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)(
- mkPut({ mood: false }),
- );
- expect(res.status).toBe(200);
- const env = (await res.json()) as { data: typeof current };
- const merged = {
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...current,
- mood: false,
- };
- expect(env.data).toEqual(merged);
- expect(prisma.user.update).toHaveBeenCalledWith({
- where: { id: "user-1" },
- data: { doctorReportPrefsJson: merged },
- });
- });
-
- // ── 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)(
- 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)(
- 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)(
- 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 }),
- }),
- }),
- );
- });
-});
diff --git a/src/app/api/auth/me/doctor-report-prefs/route.ts b/src/app/api/auth/me/doctor-report-prefs/route.ts
deleted file mode 100644
index a3fee47b7..000000000
--- a/src/app/api/auth/me/doctor-report-prefs/route.ts
+++ /dev/null
@@ -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);
-});
diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts
new file mode 100644
index 000000000..06b9706b0
--- /dev/null
+++ b/src/app/api/auth/me/report-selection/route.ts
@@ -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/saved-profile";
+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 });
+});
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index de9f67fe2..bf5a48e39 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -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:` 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:` 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,45 +137,34 @@ 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,
- },
+ select: { insuranceNumberEncrypted: 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.
- //
- // Gated on the section toggles. These two reads fed ONLY the
- // FHIR bundle, and they ran unconditionally: `sections.allergies = false`
- // produced a PDF without an allergy section and a bundle carrying every
- // AllergyIntolerance, and in the `package` format both shipped in the
- // same zip. Zero DB read when off, matching how the aggregator treats
- // mood — deselected data should not be loaded, not merely dropped later.
- sections.allergies
+ // 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([]),
- sections.familyHistory
+ selection.has("FAMILY_HISTORY")
? prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
@@ -171,16 +172,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
: 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 {
@@ -188,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,
@@ -236,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, {
@@ -266,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, {
@@ -297,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;
- 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;
- }
-}
diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts
index 1d39f38d3..8db19ac72 100644
--- a/src/app/api/meta/capabilities/route.ts
+++ b/src/app/api/meta/capabilities/route.ts
@@ -47,7 +47,10 @@ import {
FHIR_SEARCH_PARAMS,
} from "@/lib/fhir/rest";
import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link";
-import { exportSectionsSchema } from "@/lib/validations/health-record-export";
+import {
+ ALL_LEAF_IDS,
+ REPORT_GROUP_ORDER,
+} from "@/lib/report-selection/catalogue";
export const dynamic = "force-dynamic";
@@ -113,17 +116,22 @@ export const GET = apiHandler(async () => {
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,
},
});
});
diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts
index 8f8fb22f3..a98fd2fc8 100644
--- a/src/app/api/share-links/route.ts
+++ b/src/app/api/share-links/route.ts
@@ -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,
@@ -66,6 +74,10 @@ const shareLinkSummarySelect = {
rangeStart: true,
rangeEnd: 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,
@@ -75,6 +87,22 @@ 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;
@@ -82,6 +110,7 @@ function toSummary(row: {
rangeStart: Date;
rangeEnd: Date | null;
documentOnly: boolean;
+ sectionsJson: unknown;
passphraseHash: string | null;
expiresAt: Date;
createdAt: Date;
@@ -98,6 +127,14 @@ function toSummary(row: {
// 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,
@@ -134,16 +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
- // column 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: an
- // explicit all-OFF prefs blob, distinct from the `{}` that means "full record
- // defaults". "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 ?? {});
- 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")}`;
@@ -217,6 +279,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess(
{
...toSummary(created),
+ ...RETIRED_FHIR_SCOPE_FIELDS,
token: rawToken,
passphrase: rawPassphrase,
shareUrl,
@@ -236,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,
+ })),
+ });
});
diff --git a/src/app/c/[token]/fhir/route.ts b/src/app/c/[token]/fhir/route.ts
new file mode 100644
index 000000000..82a25f1d6
--- /dev/null
+++ b/src/app/c/[token]/fhir/route.ts
@@ -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",
+ },
+ });
+ },
+);
diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx
index 51582adcf..cfef75753 100644
--- a/src/app/c/[token]/page.tsx
+++ b/src/app/c/[token]/page.tsx
@@ -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 {
- 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}
diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts
new file mode 100644
index 000000000..e2e4dd281
--- /dev/null
+++ b/src/app/c/[token]/report.pdf/route.ts
@@ -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",
+ },
+ });
+ },
+);
diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx
index d6d583b1c..89e367317 100644
--- a/src/components/clinician/clinician-view.tsx
+++ b/src/components/clinician/clinician-view.tsx
@@ -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//d/`), 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 = {
- 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 (
-
- {label}
- {value}
-
- );
-}
-
-function Section({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-/**
- * One shared document. Class A (inline) renders a preview straight at the
- * token-scoped serve route — an `
` 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 (
-
-
-
- {Icon ? (
-
- ) : null}
-
-
{title}
- {meta.length > 0 ? (
-
- {meta.join(" · ")}
-
- ) : null}
-
-
- {/* Download is always available (inline or attachment) so the recipient
- can keep a copy; the anchor hits the same token-scoped serve route. */}
-
-
- {t("clinicianView.documents.download")}
-
-
-
- {isInline ? (
-
- {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
-

- ) : (
-
- )}
-
- ) : (
-
- {t("clinicianView.documents.attachmentHint")}
-
- )}
-
- );
-}
-
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 (
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
+ {report && !documentOnly && token ? (
+
+ ) : null}
{t("clinicianView.provenance")}
- {/* ── Clinical vitals ─────────────────────────────────────── */}
- {measurementEntries.length > 0 ? (
-
- {measurementEntries.map(([type, s]) => (
-
- ))}
- {report &&
- report.bmi !== null &&
- report.bmi !== undefined &&
- sections.bmi ? (
-
+ {report ? (
+ <>
+
+ {report.bmi !== null && report.bmi !== undefined ? (
+
) : null}
-
- ) : null}
-
- {/* ── Labs (glucose) ──────────────────────────────────────── */}
- {glucoseEntries.length > 0 ? (
-
- {glucoseEntries.map(([ctx, s]) => (
-
- ))}
-
- ) : null}
-
- {/* ── Medications + adherence ─────────────────────────────── */}
- {medications.length > 0 ? (
-
- {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 (
-
- );
- })}
- {complianceEntries
- .filter(([name]) => !medications.some((m) => m.name === name))
- .map(([name, c]) => (
-
- ))}
-
- ) : null}
-
- {/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
- {wellness.length > 0 ? (
-
-
- {t("clinicianView.wellness.title")}
-
-
- {t("clinicianView.wellness.disclaimer")}
-
-
- {wellness.map((s) => (
-
- ))}
-
-
+
+
+
+ >
) : null}
{/* ── Shared documents ────────────────────────────────────── */}
diff --git a/src/components/clinician/documents-list.tsx b/src/components/clinician/documents-list.tsx
new file mode 100644
index 000000000..b5850b2dd
--- /dev/null
+++ b/src/components/clinician/documents-list.tsx
@@ -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 `
![]()
` 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;
+
+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 (
+
+
+
+ {Icon ? (
+
+ ) : null}
+
+
{title}
+ {meta.length > 0 ? (
+
+ {meta.join(" · ")}
+
+ ) : null}
+
+
+ {/* Download is always available (inline or attachment) so the recipient
+ can keep a copy; the anchor hits the same token-scoped serve route. */}
+
+
+ {t("clinicianView.documents.download")}
+
+
+
+ {isInline ? (
+
+ {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
+

+ ) : (
+
+ )}
+
+ ) : (
+
+ {t("clinicianView.documents.attachmentHint")}
+
+ )}
+
+ );
+}
diff --git a/src/components/clinician/download-actions.tsx b/src/components/clinician/download-actions.tsx
new file mode 100644
index 000000000..137282fb6
--- /dev/null
+++ b/src/components/clinician/download-actions.tsx
@@ -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;
+
+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 (
+
+ );
+}
diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx
new file mode 100644
index 000000000..60b4008cb
--- /dev/null
+++ b/src/components/clinician/report-sections.tsx
@@ -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;
+
+/** Human-readable display per persisted wellness-score type (i18n key suffix). */
+const WELLNESS_KEY: Record = {
+ 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 (
+
+ {label}
+ {value}
+
+ );
+}
+
+export function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * 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) => (
+
+ {group.rows.map(({ type, stat }) => (
+
+ ))}
+
+ ))}
+ >
+ );
+}
+
+/** 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 (
+
+ {entries.map(([ctx, s]) => (
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+ {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 (
+
+ );
+ })}
+ {complianceEntries
+ .filter(([name]) => !medications.some((m) => m.name === name))
+ .map(([name, c]) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ {t("clinicianView.wellness.title")}
+
+
+ {t("clinicianView.wellness.disclaimer")}
+
+
+ {wellness.map((s) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/lib/clinician-share/report-download.ts b/src/lib/clinician-share/report-download.ts
new file mode 100644
index 000000000..b341a1afe
--- /dev/null
+++ b/src/lib/clinician-share/report-download.ts
@@ -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 `:${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 {
+ 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/` 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 };
+}
diff --git a/src/lib/clinician-share/request-locale.ts b/src/lib/clinician-share/request-locale.ts
new file mode 100644
index 000000000..3204be4cd
--- /dev/null
+++ b/src/lib/clinician-share/request-locale.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts
index 88cc3676c..b6f994412 100644
--- a/src/lib/clinician-share/share-view-data.ts
+++ b/src/lib/clinician-share/share-view-data.ts
@@ -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 {
- // 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 };
}
/**
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index f5085a054..03d83687b 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -1,1428 +1,14 @@
/**
- * Server-side aggregator for doctor-report data.
+ * The doctor-report aggregator's public surface.
*
- * Single source of truth for the aggregated payload consumed by both
- * `/api/doctor-report` (JSON, client renders PDF) and
- * `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
- * structurally identical so visual parity between client- and server-rendered
- * PDFs is guaranteed by construction, not by drift-prone copy-paste.
+ * The work itself lives under `src/lib/doctor-report/` — the selection gate,
+ * the dense-bucket query, the per-type series, the glucose panel, the
+ * medication ledger, the structured clinical records and the orchestrator that
+ * composes them. It was one 1,400-line function doing a dozen jobs; a gate
+ * buried in the middle of that is a gate nobody re-reads.
*
- * The data shapes live in `doctor-report-types.ts` and the pure helpers in
- * `doctor-report-helpers.ts`; both are re-exported here so call sites keep a
- * single import surface.
+ * This module stays the single import path so no call site had to change.
*/
-
-import { prisma } from "@/lib/db";
-import {
- getEffectiveRange,
- type ThresholdOverridesJson,
-} from "@/lib/analytics/effective-range";
-import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
-import {
- computeGlucoseClinicalMetrics,
- CV_INSTABILITY_THRESHOLD,
- estimatedA1c,
- gmi,
- type GlucoseClinicalMetrics,
-} from "@/lib/analytics/glucose-metrics";
-import type {
- GlucoseContext,
- MeasurementSource,
- MeasurementType,
-} from "@/generated/prisma/client";
-import { getEvent } from "@/lib/logging/context";
-import { readNote } from "@/lib/crypto/note-cipher";
-import {
- reconstructSleepNights,
- type SleepStageRow,
-} from "@/lib/analytics/sleep-night";
-import {
- DEFAULT_DOCTOR_REPORT_PREFS,
- type DoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
-import { buildCycleExportSummary } from "@/lib/cycle/export-data";
-import { resolveModuleMap } from "@/lib/modules/gate";
-import {
- excludedMeasurementTypes as excludedMeasurementTypesForSelection,
- filterMeasurementKeys,
-} from "@/lib/doctor-report-selection";
-import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
-import {
- WELLNESS_SCORE_REPORT_TYPES,
- type CollectDoctorReportOptions,
- type DoctorReportData,
- type DoctorReportMood,
- type DoctorReportRange,
- type DoctorReportStats,
-} from "./doctor-report-types";
-import {
- buildLedgerCompliance,
- collapseMeasurementsToCanonical,
- decryptAllergyReaction,
- minMaxOf,
- resolveMaxMedicationAdministrations,
- sanitisePracticeName,
- summariseCanonicalRecovery,
-} from "./doctor-report-helpers";
-
export * from "./doctor-report-types";
export * from "./doctor-report-helpers";
-
-const GLUCOSE_CONTEXTS: GlucoseContext[] = [
- "FASTING",
- "POSTPRANDIAL",
- "RANDOM",
- "BEDTIME",
-];
-
-const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
-
-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;
-}
-
-interface DenseMeasurementSummary {
- byType: Record>;
- stats: Record;
- glucoseStats: Record;
- glucoseClinical: GlucoseClinicalMetrics;
-}
-
-interface DenseStatsAccumulator {
- count: number;
- sum: number;
- min: number;
- max: number;
- latestAt: Date;
- latest: number;
-}
-
-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,
- };
-}
-
-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,
- };
-}
-
-function summariseDenseBuckets(
- rows: readonly DenseMeasurementBucket[],
- timezone: string,
- sourcePriorityJson: unknown,
- windowDays: number,
-): DenseMeasurementSummary {
- const canonical = collapseMeasurementsToCanonical(
- rows.map((row) => ({
- ...row,
- value: row.sumValue / row.count,
- measuredAt: row.bucketStart,
- })),
- timezone,
- sourcePriorityJson,
- );
- const byType: DenseMeasurementSummary["byType"] = {};
- const statsAcc = new Map();
- const glucoseStatsAcc = new Map();
-
- const byTypeDay = new Map<
- string,
- {
- type: string;
- bucketStart: Date;
- count: number;
- sum: number;
- min: number;
- max: number;
- latestAt: Date;
- latest: number;
- }
- >();
- for (const row of canonical) {
- const key = `${row.type}:${row.bucketStart.toISOString()}`;
- const current = byTypeDay.get(key);
- if (!current) {
- byTypeDay.set(key, {
- type: row.type,
- bucketStart: row.bucketStart,
- count: row.count,
- sum: row.sumValue,
- min: row.minValue,
- max: row.maxValue,
- latestAt: row.latestAt,
- latest: row.latestValue,
- });
- continue;
- }
- current.count += row.count;
- current.sum += row.sumValue;
- current.min = Math.min(current.min, row.minValue);
- current.max = Math.max(current.max, row.maxValue);
- if (row.latestAt > current.latestAt) {
- current.latestAt = row.latestAt;
- current.latest = row.latestValue;
- }
- }
-
- const accumulate = (
- target: Map,
- key: string,
- row: DenseStatsAccumulator,
- ) => {
- const current = target.get(key);
- if (!current) {
- target.set(key, { ...row });
- return;
- }
- current.count += row.count;
- current.sum += row.sum;
- current.min = Math.min(current.min, row.min);
- current.max = Math.max(current.max, row.max);
- if (row.latestAt > current.latestAt) {
- current.latestAt = row.latestAt;
- current.latest = row.latest;
- }
- };
-
- for (const row of byTypeDay.values()) {
- (byType[row.type] ??= []).push({
- value: row.sum / row.count,
- measuredAt: row.bucketStart.toISOString(),
- });
- accumulate(statsAcc, row.type, row);
- }
-
- for (const row of canonical) {
- if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
- accumulate(glucoseStatsAcc, row.glucoseContext, {
- count: row.count,
- sum: row.sumValue,
- min: row.minValue,
- max: row.maxValue,
- latestAt: row.latestAt,
- latest: row.latestValue,
- });
- }
-
- for (const entries of Object.values(byType)) {
- entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
- }
- const stats: DenseMeasurementSummary["stats"] = {};
- for (const [key, value] of statsAcc) {
- stats[key] = {
- count: value.count,
- avg: value.sum / value.count,
- min: value.min,
- max: value.max,
- latest: value.latest,
- };
- }
- const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {};
- for (const [key, value] of glucoseStatsAcc) {
- glucoseStats[key] = {
- count: value.count,
- avg: value.sum / value.count,
- min: value.min,
- max: value.max,
- latest: value.latest,
- };
- }
-
- return {
- byType,
- stats,
- glucoseStats,
- glucoseClinical: glucoseClinicalFromBuckets(canonical, windowDays),
- };
-}
-
-/**
- * Aggregate the doctor-report payload for a user over a `[start, end]` range.
- * Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
- *
- * `range` is validated via `normaliseDateRange()` upstream; this function
- * trusts it and uses both bounds in the Prisma `where` clause so a custom
- * window (not just "last N days") filters correctly.
- */
-export async function collectDoctorReportData(
- userId: string,
- range: DoctorReportRange,
- options: CollectDoctorReportOptions = {},
-): Promise {
- const { start, end, days } = range;
-
- // Resolve section toggles up-front. Mood is privacy-sensitive: when
- // it's off we don't issue the `MoodEntry` findMany at all so the data
- // never lands in this process's memory. Other sections are queried
- // either way (the queries are cheap on indexed columns) and stripped
- // from the returned payload below — keeping a single source of
- // truth for the data shape regardless of toggle state.
- // v1.18.0 — resolve the per-user module map up-front (once per build). A
- // disabled module's section/resources are excluded from the export so the
- // PDF + FHIR bundle reflect only the modules the user keeps. The map is
- // ANDed over the user's per-report `sections` toggles: a disabled module
- // forces its section off even if the report toggle says on (you can't share
- // data for a module you turned off), while leaving the others untouched.
- // Core clinical sections (weight / BP / pulse / medications) have no module
- // key and stay unconditional. Injectable for tests.
- const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId));
-
- // v1.30.22 — fail-closed backstop on the aggregate's OWN module key,
- // mirroring `loadFhirContext`.
- //
- // Everything below gates the per-domain SECTIONS (mood / sleep / glucose /
- // cycle / labs / recovery / workouts) but nothing ever gated `doctorReport`
- // itself — the key that decides whether the whole-record aggregate may be
- // assembled at all. That left it entirely to callers to remember, and the
- // MCP surface did not: `healthlog://report/doctor-visit`, its `{window}`
- // template, and the `doctor_visit_summary` prompt all assembled the full
- // record for an account with the module off, on the one wire that egresses
- // to a third-party assistant.
- //
- // REFUSE, not omit — the opposite of the per-domain reads. This is the
- // whole-record aggregate, so there is no honest partial answer to return;
- // `/api/export/health-record` already answers 403 for exactly this state
- // and this is the same payload. 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 (see the MCP resources and the clinician share).
- if (moduleMap.doctorReport === false) {
- throw new Error(
- 'collectDoctorReportData called with the "doctorReport" module ' +
- "disabled — the calling surface is missing its module gate",
- );
- }
-
- const sections: DoctorReportPrefs = {
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...(options.sections ?? {}),
- };
- // Module gate: a disabled module wins over the report toggle. Each report
- // section maps to its owning module key; core sections (bp/weight/pulse/
- // bmi/compliance) carry no key and are never gated here.
- if (moduleMap.mood === false) sections.mood = false;
- if (moduleMap.sleep === false) sections.sleep = false;
- if (moduleMap.glucose === false) sections.glucose = false;
- if (moduleMap.cycle === false) sections.cycle = false;
- if (moduleMap.labs === false) sections.labs = false;
- // Module gates with no `sections` key — applied directly to the data slices
- // below (recovery/strain wellness scores, workout series). Glucose now
- // carries its own report section toggle, into which the module gate above
- // already folds — so the section flag is the single lever for the glucose
- // panel (stats + ranges + clinical metrics).
- const glucoseEnabled = sections.glucose !== false;
- const recoveryEnabled = moduleMap.recovery !== false;
- const workoutsEnabled = moduleMap.workouts !== false;
-
- // v1.28.25 — the section / module gates are pushed INTO the measurement
- // query (see `doctor-report-selection.ts` for why, and for the one type the
- // query must keep regardless). Every sparse type keeps the raw-row path; on
- // long windows the two sample-dense types move to one SQL row per local
- // day/source/device/context, and the canonical-source picker then works over
- // that bounded intermediate set.
- const excludedMeasurementTypes = excludedMeasurementTypesForSelection(
- sections,
- moduleMap,
- );
-
- 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 && !excludedMeasurementTypes.includes("PULSE");
- const denseGlucose =
- aggregateDenseTypes && !excludedMeasurementTypes.includes("BLOOD_GLUCOSE");
- const rawExcludedMeasurementTypes = [...excludedMeasurementTypes];
- if (densePulse) rawExcludedMeasurementTypes.push("PULSE");
- if (denseGlucose) rawExcludedMeasurementTypes.push("BLOOD_GLUCOSE");
-
- const [measurements, medications, intakeEvents, moodEntries, denseBuckets] =
- await Promise.all([
- prisma.measurement.findMany({
- // v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
- // the doctor-report aggregator so deleted rows never leak into
- // the JSON payload or the server-rendered PDF.
- where: {
- userId,
- measuredAt: { gte: start, lte: end },
- deletedAt: null,
- ...(rawExcludedMeasurementTypes.length > 0
- ? { type: { notIn: rawExcludedMeasurementTypes } }
- : {}),
- },
- orderBy: { measuredAt: "asc" },
- // v1.28.25 — narrow select. The collector reads exactly these seven
- // fields (canonical-source collapse: type / value / measuredAt /
- // source / deviceType; sleep-night reconstruction adds sleepStage;
- // the glucose panel adds glucoseContext). The full-width read pulled
- // every column — including the notesEncrypted Bytes — across up to
- // 730 days of rows, which on a CGM + per-sample-HR account is the
- // v1.28.2x six-figure-row incident class.
- select: {
- type: true,
- value: true,
- measuredAt: true,
- source: true,
- deviceType: true,
- sleepStage: true,
- glucoseContext: true,
- },
- }),
- prisma.medication.findMany({
- where: { userId, active: true },
- include: {
- // v1.15.20 — the shared compliance select (plus `label`, which the
- // FHIR schedule mapping below needs) so a future engine column added
- // to SCHEDULE_COMPLIANCE_SELECT reaches the doctor-report surface.
- schedules: { select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true } },
- // v1.17 W1a — archived schedule eras so the ledger compliance
- // builder segments expected-slot expansion against the schedule
- // that was live on each past day (matches the detail page).
- scheduleRevisions: {
- orderBy: { validFrom: "asc" },
- select: {
- id: true,
- validFrom: true,
- validUntil: true,
- payload: true,
- supersededByRevisionId: true,
- },
- },
- // v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
- pauseEras: { select: { pausedAt: true, resumedAt: true } },
- // v1.4.25 W4d — eager-load dose history + recent intake site
- // for any active medication. Generic meds carry empty arrays
- // so the legacy data path is byte-identical.
- doseChanges: { orderBy: { effectiveFrom: "asc" } },
- intakeEvents: {
- where: { takenAt: { not: null } },
- orderBy: { takenAt: "desc" },
- take: 1,
- select: { takenAt: true, injectionSite: true },
- },
- },
- }),
- prisma.medicationIntakeEvent.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the doctor report.
- where: {
- userId,
- deletedAt: null,
- scheduledFor: { gte: start, lte: end },
- },
- include: {
- // v1.9.0 — carry the medication identity + codes + delivery
- // form so the FHIR MedicationAdministration builder can emit a
- // self-describing `medicationCodeableConcept` and a route.
- medication: {
- select: {
- id: true,
- name: true,
- dose: true,
- atcCode: true,
- rxNormCode: true,
- deliveryForm: true,
- asNeeded: true,
- },
- },
- },
- orderBy: { scheduledFor: "asc" },
- }),
- // Mood data: zero DB read when the user opted out. This is the
- // privacy-by-default contract — the JSON payload + audit log
- // both reflect "mood was never fetched", not "mood was fetched
- // and then dropped".
- sections.mood
- ? prisma.moodEntry.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the doctor report.
- where: {
- userId,
- deletedAt: null,
- moodLoggedAt: { gte: start, lte: end },
- },
- orderBy: { moodLoggedAt: "asc" },
- // v1.28.46 perf (H1) — narrow select. The collector reads exactly
- // two fields off each mood row: `score` (the mood summary /
- // distribution) and `tags` (GLP-1 side-effect tag counts). The
- // full-width read pulled every column — including the encrypted
- // `noteEncrypted` Bytes and the plaintext `note` free text — across
- // the whole report window, none of which the report ever touches.
- select: { score: true, tags: true },
- })
- : Promise.resolve([]),
- densePulse || denseGlucose
- ? prisma.$queryRaw`
- 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
- `
- : Promise.resolve([]),
- ]);
-
- const denseSummary = summariseDenseBuckets(
- denseBuckets,
- reportTz,
- userProfile?.sourcePriorityJson ?? null,
- days,
- );
-
- // v1.18.0 — collapse each multi-source metric to its CANONICAL source before
- // grouping, so the report's per-type avg/min/max match the dashboard rather
- // than blending overlapping sources (e.g. WHOOP + Apple Watch resting-heart-
- // rate summed into one inflated mean). See `collapseMeasurementsToCanonical`.
- const canonicalMeasurements = collapseMeasurementsToCanonical(
- measurements,
- reportTz,
- userProfile?.sourcePriorityJson ?? null,
- );
-
- // Group measurements by type (canonical-source-collapsed).
- 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;
- }
- // measuredAt-ascending within each type so `latest` (last element) is the
- // most recent reading after the canonical collapse reorders by bucket.
- for (const entries of Object.values(byType)) {
- entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
- }
-
- // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a
- // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface
- // (dashboard slim slice, /insights/sleep, /api/sleep/night, the iOS feed)
- // shows the per-night reconstructed asleep total via `summarizeSleepNights`,
- // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route
- // the report's sleep value through the same engine — one number, one surface
- // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`.
- 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);
- // Per-night asleep totals, ascending by night, replace the raw per-stage
- // rows so the clinical vitals table reads time-asleep hours, not stage-row
- // minutes. Empty (no scorable night) drops SLEEP_DURATION entirely.
- if (nights.length > 0) {
- byType.SLEEP_DURATION = nights.map((n) => ({
- value: n.asleepMinutes,
- measuredAt: n.measuredAt.toISOString(),
- }));
- } else {
- delete byType.SLEEP_DURATION;
- }
- }
-
- // Per-type stats.
- const stats: Record = {};
- 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);
-
- // v1.17 W1a — medication compliance through the dose-ledger authority (the
- // same engine the detail page uses), NOT a raw-row tally. Routes each
- // scheduled medication's intake rows through the cadence-aware band minter
- // over the report window so the PDF / FHIR adherence % matches the app's
- // detail page (slot dedup, band timing, cadence honoured). As-needed / PRN
- // 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,
- );
-
- // v1.9.0 — MedicationAdministration source rows. One entry per acted
- // intake (taken OR explicitly skipped); pending / missed rows are
- // dropped so the FHIR export never asserts an administration that did
- // not happen, and tombstoned rows are already excluded by the query
- // `deletedAt: null` predicate. The structured `dose` is resolved from
- // the medication's `MedicationDoseChange` history — the latest change
- // effective at or before the administration instant — when one exists.
- //
- // Dose-change history is loaded on the `medications` array (active
- // meds only); a per-medicationId index lets the resolver run in O(1)
- // lookups + a short linear scan over the (typically small) change list.
- const doseChangesByMedId = new Map<
- string,
- Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
- >();
- for (const m of medications) {
- doseChangesByMedId.set(
- m.id,
- m.doseChanges.map((dc) => ({
- effectiveFrom: dc.effectiveFrom,
- doseValue: dc.doseValue,
- doseUnit: dc.doseUnit,
- })),
- );
- }
- const resolveDoseInEffect = (
- medicationId: string,
- at: Date,
- ): { value: number; unit: string } | null => {
- const changes = doseChangesByMedId.get(medicationId);
- if (!changes || changes.length === 0) return null;
- // `doseChanges` are loaded ordered by `effectiveFrom asc`; take the
- // last one whose effectiveFrom is <= the administration instant.
- let inEffect: { value: number; unit: string } | null = null;
- for (const c of changes) {
- if (c.effectiveFrom.getTime() <= at.getTime()) {
- inEffect = { value: c.doseValue, unit: c.doseUnit };
- } else {
- break;
- }
- }
- return inEffect;
- };
-
- const medicationAdministrations: NonNullable<
- DoctorReportData["medicationAdministrations"]
- > = [];
- for (const event of intakeEvents) {
- // Only acted rows: a taken dose (completed) or an explicit skip
- // (not-done). A scheduled-but-unconfirmed ("missed") slot is not an
- // administration event and is omitted entirely.
- const isTaken = event.takenAt !== null;
- if (!isTaken && !event.skipped) continue;
- const effectiveAt = isTaken ? (event.takenAt as Date) : event.scheduledFor;
- medicationAdministrations.push({
- medicationName: event.medication.name,
- effectiveAt: effectiveAt.toISOString(),
- status: isTaken ? "completed" : "not-done",
- doseText: event.medication.dose || null,
- // Structured dose only meaningful for a taken dose with a
- // dose-change history; a skip records no dose consumed.
- dose: isTaken
- ? resolveDoseInEffect(event.medication.id, effectiveAt)
- : null,
- injectionSite: event.injectionSite ?? null,
- atcCode: event.medication.atcCode ?? null,
- rxNormCode: event.medication.rxNormCode ?? null,
- deliveryForm: event.medication.deliveryForm ?? null,
- });
- }
-
- // v1.9.0 — bound the administration set. `intakeEvents` is ordered
- // `scheduledFor: asc`, so the most-recent acted rows are at the tail;
- // keep the last N and flag the trim so the FHIR narrative can disclose
- // it. The omitted rows are the OLDEST in the window — recent adherence
- // is preserved intact. The cap is a coarse safety ceiling (the report
- // window is the natural bound; this only guards a pathological
- // multi-year, many-medication export) — resolved per call from
- // `FHIR_MAX_MEDICATION_ADMINISTRATIONS` so an operator override takes
- // effect without a code change.
- const maxMedicationAdministrations = resolveMaxMedicationAdministrations(
- process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
- );
- const totalAdministrations = medicationAdministrations.length;
- const medicationAdministrationsTruncated =
- totalAdministrations > maxMedicationAdministrations;
- const cappedAdministrations = medicationAdministrationsTruncated
- ? medicationAdministrations.slice(
- totalAdministrations - maxMedicationAdministrations,
- )
- : medicationAdministrations;
-
- // Mood summary.
- 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 latest weight + profile height.
- 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).
- const glucoseStats: Record = denseGlucose
- ? { ...denseSummary.glucoseStats }
- : {};
- const glucoseRanges: Record = {};
- // v1.18.0 — when the glucose module is disabled, no glucose row enters the
- // panel: stats, ranges, and the clinical metrics all collapse to empty so the
- // PDF + FHIR Observations carry no glucose for this user.
- const glucoseRows = glucoseEnabled
- ? 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,
- };
- 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 };
- }
- }
-
- // v1.17.0 — clinical panel over the WHOLE report period (all contexts
- // pooled), computed by the one literature-locked engine the insights panel
- // and the coach also consume. `windowDays` is the report's own period so the
- // TIR / GMI / eA1C / CV% reflect exactly the readings the rest of the report
- // tabulates; `now: end` anchors the window to the report's upper bound. The
- // learning gate keeps a thin period from asserting a clinical AGP off spot
- // data. Values stay canonical mg/dL; the renderer converts with `glucoseUnit`.
- const glucoseClinical = denseGlucose
- ? denseSummary.glucoseClinical
- : computeGlucoseClinicalMetrics(
- glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
- { windowDays: days, now: end },
- );
-
- // `practiceName` is sanitised to a single-line string with a hard length
- // cap. The PDF cover prints it verbatim — never let unbounded input land in
- // a layout-sensitive header.
- const practiceName = sanitisePracticeName(options.practiceName);
-
- // Apply section toggles. Removing the type's keys from `byType`,
- // `stats`, and `compliance` lets the PDF renderer treat "section
- // disabled" identically to "section had no rows" — both paths skip
- // the table. Mood is already null when disabled (we never queried).
- const filteredByType = filterMeasurementKeys(byType, sections, moduleMap);
- const filteredStats = filterMeasurementKeys(stats, sections, moduleMap);
- const filteredCompliance = sections.compliance ? compliance : {};
- const filteredBmi = sections.bmi ? bmi : null;
-
- // v1.4.25 W4d — GLP-1 therapy section. Only assembled when the user
- // has at least one active GLP-1 medication AND the compliance toggle
- // is on (per the privacy contract: a doctor-report dose history needs
- // the compliance section enabled to be useful). Generic accounts
- // get `glp1: null` which the PDF renderer skips entirely.
- const glp1Meds = medications.filter((m) => m.treatmentClass === "GLP1");
- let glp1: DoctorReportData["glp1"] = null;
- if (sections.compliance && glp1Meds.length > 0) {
- const weightSorted = (byType.WEIGHT ?? []).slice();
- const weightStartKg = weightSorted[0]?.value ?? null;
- const weightEndKg = weightSorted[weightSorted.length - 1]?.value ?? null;
- const weightDeltaKg =
- weightStartKg !== null && weightEndKg !== null
- ? Math.round((weightEndKg - weightStartKg) * 10) / 10
- : null;
-
- // Side-effect tag counts (only when mood section is enabled — we
- // already gated the moodEntries read on `sections.mood`, so this
- // collapses to "no tags" when the user opted out).
- const sideEffectTags = new Set([
- "nausea",
- "constipation",
- "diarrhea",
- "fatigue",
- "appetite-loss",
- "heartburn",
- "headache",
- "übelkeit",
- "verstopfung",
- "durchfall",
- "müdigkeit",
- "appetitlosigkeit",
- "sodbrennen",
- "kopfschmerzen",
- ]);
- const sideEffectCounts = new Map();
- for (const mood of moodEntries) {
- const rawTags = mood.tags ?? "";
- let tags: string[];
- try {
- const parsed = JSON.parse(rawTags);
- tags = Array.isArray(parsed)
- ? parsed
- .map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
- .filter(Boolean)
- : [];
- } catch {
- tags = rawTags
- .split(",")
- .map((p) => p.trim())
- .filter(Boolean);
- }
- for (const tag of tags) {
- const lower = tag.toLowerCase();
- if (!sideEffectTags.has(lower)) continue;
- sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
- }
- }
-
- glp1 = {
- medications: glp1Meds.map((m) => {
- const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
- const lastIntake = m.intakeEvents[0] ?? null;
- const comp = compliance[m.name] ?? {
- taken: 0,
- total: 0,
- skipped: 0,
- missed: 0,
- };
- return {
- name: m.name,
- currentDose: latest
- ? {
- value: latest.doseValue,
- unit: latest.doseUnit,
- since: latest.effectiveFrom.toISOString(),
- }
- : null,
- doseHistory: m.doseChanges.map((dc) => ({
- value: dc.doseValue,
- unit: dc.doseUnit,
- effectiveFrom: dc.effectiveFrom.toISOString(),
- note: readNote(dc.noteEncrypted, dc.note),
- })),
- lastInjection:
- lastIntake && lastIntake.takenAt
- ? {
- date: lastIntake.takenAt.toISOString(),
- site: lastIntake.injectionSite,
- }
- : null,
- compliance: { taken: comp.taken, total: comp.total },
- };
- }),
- weightStartKg,
- weightEndKg,
- weightDeltaKg,
- sideEffects: Array.from(sideEffectCounts.entries())
- .sort((a, b) => b[1] - a[1])
- .map(([tag, count]) => ({ tag, count })),
- };
- }
-
- // v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
- // in `byType`/`stats` (they're only filtered out of the clinical vitals
- // table). Summarise each present score type for the separate "Wellness
- // summary" section. Empty array → null so the renderer + FHIR builder skip
- // the section entirely.
- //
- // RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
- // the same day; mixing them into one min/avg/max would blend two distinct
- // series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
- // the SAME value the tile + iOS feed show — one number, one engine.
- const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
- // v1.18.0 — module gate per score type. Recovery + stress are the
- // recovery/readiness signals (recovery module); strain is the
- // training-load signal derived from the workouts table (workouts module).
- // A disabled owning module drops the score from the wellness summary and,
- // since the FHIR exporter sources these Observations from
- // `data.wellnessScores`, from the bundle too.
- if (type === "STRAIN_SCORE" && !workoutsEnabled) return [];
- if (
- (type === "RECOVERY_SCORE" || type === "STRESS_SCORE") &&
- !recoveryEnabled
- ) {
- 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;
-
- // v1.15.0 — cycle summary. Privacy default OFF: only read + assemble when
- // the `cycle` section toggle is explicitly ON. Statistics only — the
- // helper never touches `notesEncrypted`, so no plaintext free-text can
- // leak through this surface. `null` when disabled or no observed cycle.
- let cycle: DoctorReportData["cycle"] = null;
- if (sections.cycle) {
- cycle = await buildCycleExportSummary(
- userId,
- end.toISOString().slice(0, 10),
- );
- }
-
- // v1.17.1 — structured lab results over the window. ON by default; the
- // user recorded these specifically to share with a clinician, so the
- // privacy stance matches BP / weight, not mood / cycle. We reduce to the
- // latest reading per analyte (with a count) so the report is a concise
- // panel, not a raw dump; notes are never read here.
- let labResults: DoctorReportData["labResults"] = null;
- if (sections.labs) {
- const labRows = 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,
- },
- });
- // Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold
- // together; rows are ascending so the last seen wins as the latest.
- const byAnalyte = new Map<
- string,
- NonNullable[number]
- >();
- for (const r of labRows) {
- 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());
- labResults = collapsed.length > 0 ? collapsed : null;
- }
-
- // v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
- // on the illness module (default-on, opt-out): when off, no read, no section.
- // 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 + dates only — the encrypted note is never selected, so no
- // free-text leaks into the clinical export.
- let illnessEpisodes: DoctorReportData["illnessEpisodes"] = null;
- if (moduleMap.illness !== false) {
- const episodeRows = 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 = episodeRows.map((e) => ({
- label: e.label,
- type: e.type,
- lifecycle: e.lifecycle,
- onsetAt: e.onsetAt.toISOString(),
- resolvedAt: e.resolvedAt ? e.resolvedAt.toISOString() : null,
- }));
- illnessEpisodes = mapped.length > 0 ? mapped : null;
- }
-
- // v1.27.x — structured allergies + family history. Reference data, not
- // time-windowed (a penicillin allergy does not expire with the report
- // window), so no date filter. Riding the section toggles keeps the "you
- // can deselect any section" contract; both default ON — a clinical
- // report without an allergy section is the riskier default. The
- // reaction description decrypts fail-soft per row; the free-text notes
- // are never selected, matching the illness-journal stance.
- let allergies: DoctorReportData["allergies"] = null;
- if (sections.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) {
- // A reaction WAS recorded but is undecryptable (key gap / GCM
- // corruption). It is flagged (not silently blanked) so the PDF renders
- // an honest "unreadable" marker; log the swallowed decrypt so a
- // systemic key-config failure is visible rather than masquerading as
- // "no reaction recorded" on a clinician-facing export.
- 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,
- };
- });
- allergies = mapped.length > 0 ? mapped : null;
- }
-
- let familyHistory: DoctorReportData["familyHistory"] = null;
- if (sections.familyHistory) {
- const rows = await prisma.familyHistoryEntry.findMany({
- where: { userId, deletedAt: null },
- orderBy: { createdAt: "asc" },
- select: { relationship: true, condition: true, ageAtOnset: true },
- });
- familyHistory = rows.length > 0 ? rows : null;
- }
-
- return {
- period: {
- days,
- // `since` is preserved for backwards compatibility with any in-flight
- // clients that read the old field name; mirrors `start`.
- since: start.toISOString(),
- start: start.toISOString(),
- end: end.toISOString(),
- },
- patient: {
- username: userProfile?.username ?? null,
- dateOfBirth: userProfile?.dateOfBirth
- ? userProfile.dateOfBirth.toISOString()
- : null,
- gender: userProfile?.gender ?? null,
- heightCm: userProfile?.heightCm ?? null,
- fullName: userProfile?.fullName ?? null,
- insurerName: userProfile?.insurerName ?? null,
- insurerIkNumber: userProfile?.insurerIkNumber ?? null,
- },
- practiceName,
- measurements: filteredByType,
- stats: filteredStats,
- glucoseStats,
- glucoseRanges,
- // Surfaced by the insights glucose views; the doctor-PDF / FHIR rendering of
- // these clinical metrics is staged for a later release, so no report
- // consumer reads this field yet — it is forward-populated, not dead.
- glucoseClinical,
- glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
- bmi: filteredBmi,
- compliance: filteredCompliance,
- // The medication list and the per-dose administration ledger now
- // ride `sections.medicationList`. Both were exported unconditionally while
- // the panel's single medication control claimed in its own comment to
- // exclude all medication data, so a user who switched medications off still
- // handed over every drug name, dose, schedule, injection site and skipped
- // dose in the FHIR bundle. The rows are still READ (compliance and the
- // GLP-1 block are computed from them); they no longer leave the function.
- medications: sections.medicationList
- ? medications.map((m) => ({
- name: m.name,
- dose: m.dose,
- // v1.9.0 — drug-classification codes for the coded FHIR concept.
- atcCode: m.atcCode,
- rxNormCode: m.rxNormCode,
- schedules: m.schedules.map((s) => ({
- windowStart: s.windowStart,
- windowEnd: s.windowEnd,
- label: s.label,
- })),
- }))
- : [],
- medicationAdministrations: sections.medicationList
- ? cappedAdministrations
- : [],
- medicationAdministrationsTruncation:
- sections.medicationList && medicationAdministrationsTruncated
- ? {
- total: totalAdministrations,
- included: cappedAdministrations.length,
- }
- : null,
- mood,
- glp1,
- wellnessScores,
- cycle,
- labResults,
- illnessEpisodes,
- allergies,
- familyHistory,
- };
-}
+export { collectDoctorReportData } from "./doctor-report/collect";
diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts
index 11d58427c..0dbf833f9 100644
--- a/src/lib/doctor-report-pdf-core.ts
+++ b/src/lib/doctor-report-pdf-core.ts
@@ -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 = {
WEIGHT: "kg",
@@ -183,50 +182,26 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = {
};
/**
- * 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,
};
diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
index 03d905f6e..efdc85da8 100644
--- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
+++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
@@ -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);
}
diff --git a/src/lib/doctor-report-pdf/clinical-summary.ts b/src/lib/doctor-report-pdf/clinical-summary.ts
index d4fef4032..9543f5e45 100644
--- a/src/lib/doctor-report-pdf/clinical-summary.ts
+++ b/src/lib/doctor-report-pdf/clinical-summary.ts
@@ -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,
}),
diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts
index c517e82a3..c13cfddca 100644
--- a/src/lib/doctor-report-pdf/measurements-charts-section.ts
+++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts
@@ -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 }];
+
+/**
+ * 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,
diff --git a/src/lib/doctor-report-pdf/render-context.ts b/src/lib/doctor-report-pdf/render-context.ts
index f306f4d1b..f5f1c2d85 100644
--- a/src/lib/doctor-report-pdf/render-context.ts
+++ b/src/lib/doctor-report-pdf/render-context.ts
@@ -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;
}
diff --git a/src/lib/doctor-report-selection.ts b/src/lib/doctor-report-selection.ts
deleted file mode 100644
index 5a18a2d9c..000000000
--- a/src/lib/doctor-report-selection.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * Which measurement types a doctor-report selection admits.
- *
- * Extracted from `doctor-report-data.ts` alongside the gating fix that made
- * the export panel's controls real. The aggregator is a long file that reads
- * a dozen data domains; the question "does this selection admit this
- * measurement type?" is a separate job with a separate shape, it is the one
- * the export panel's controls resolve to, and it is the piece the grouped
- * selection work will grow. It answers to two independent gates, and both
- * must pass:
- *
- * - the per-report SECTION toggle the user chose for this artefact, and
- * - the app-wide MODULE switch that decides whether the domain exists here
- * at all.
- *
- * Both maps and the filter that reads them live together so a type can never
- * be gated in the query and ungated in the output, or the reverse.
- */
-import type { MeasurementType } from "@/generated/prisma/client";
-import type { ModuleKey } from "@/lib/modules/gate";
-import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
-
-/**
- * Per-measurement-type → section-toggle mapping. This is the one enforcement
- * point for "the user deselected this data": `filterMeasurementKeys` strips a
- * gated type from `measurements` + `stats`, and PDF, FHIR bundle, package,
- * clinician share view and MCP all read those two maps — so one entry here
- * gates every format at once.
- *
- * The map used to hold five types, which is why the export panel
- * could render controls for SpO₂, body fat, body composition, resting heart
- * rate, HRV, VO₂max, steps and distance and change nothing at all. Every
- * control the panel renders now resolves to a section key that appears here (or
- * to a structured section gated by name in the aggregator body), and the
- * control-gating guard test fails the build if that stops being true.
- *
- * Glucose DOES carry a `glucose` section toggle, but `BLOOD_GLUCOSE` is gated
- * at query-exclusion time (mirroring sleep) and its panel collapses via
- * `glucoseEnabled`, so it does not ride this map.
- */
-export const MEASUREMENT_TYPE_SECTION: Record =
- {
- BLOOD_PRESSURE_SYS: "bp",
- BLOOD_PRESSURE_DIA: "bp",
- WEIGHT: "weight",
- PULSE: "pulse",
- SLEEP_DURATION: "sleep",
- OXYGEN_SATURATION: "oxygenSaturation",
- BODY_FAT: "bodyFat",
- // The scale-derived composition family: everything a body-composition scale
- // reports beyond the plain weight and fat percentage.
- TOTAL_BODY_WATER: "bodyComposition",
- BONE_MASS: "bodyComposition",
- FAT_MASS: "bodyComposition",
- FAT_FREE_MASS: "bodyComposition",
- MUSCLE_MASS: "bodyComposition",
- LEAN_BODY_MASS: "bodyComposition",
- VISCERAL_FAT: "bodyComposition",
- RESTING_HEART_RATE: "restingHeartRate",
- // Both HRV estimators ride one control: SDNN and RMSSD are the same signal to
- // a reader deciding whether to hand over heart-rate variability at all.
- HEART_RATE_VARIABILITY: "hrv",
- HRV_RMSSD: "hrv",
- VO2_MAX: "vo2max",
- ACTIVITY_STEPS: "steps",
- WALKING_RUNNING_DISTANCE: "distance",
- // Screening totals. The `mentalHealth` module gate below still applies; this
- // is the per-artefact choice that gate stopped standing in for.
- PHQ9_SCORE: "mentalHealthScreeners",
- GAD7_SCORE: "mentalHealthScreeners",
- WHO5_SCORE: "mentalHealthScreeners",
- SCI_SCORE: "mentalHealthScreeners",
- };
-
-/**
- * v1.18.0 — per-measurement-type → owning module mapping for the types that
- * carry no `sections` toggle but DO belong to a toggleable module. When the
- * module is disabled, the type is stripped from `measurements` + `stats` so the
- * series never reaches the PDF tables or a FHIR Observation.
- *
- * - `workouts` owns the activity / movement series (steps, active energy,
- * distance, flights, the walking-gait + stair metrics) and the
- * training-load `STRAIN_SCORE`.
- * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`.
- * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is
- * gated where it is assembled; this strips the raw rows too).
- *
- * Core clinical types (BP / weight / pulse / body-composition / vitals) carry
- * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride
- * the `sections` mechanism instead.
- */
-export const MEASUREMENT_TYPE_MODULE: Record = {
- ACTIVITY_STEPS: "workouts",
- ACTIVE_ENERGY_BURNED: "workouts",
- FLIGHTS_CLIMBED: "workouts",
- WALKING_RUNNING_DISTANCE: "workouts",
- WALKING_SPEED: "workouts",
- WALKING_ASYMMETRY: "workouts",
- WALKING_STEP_LENGTH: "workouts",
- WALKING_DOUBLE_SUPPORT: "workouts",
- SIX_MINUTE_WALK_DISTANCE: "workouts",
- STAIR_ASCENT_SPEED: "workouts",
- STAIR_DESCENT_SPEED: "workouts",
- STRAIN_SCORE: "workouts",
- RECOVERY_SCORE: "recovery",
- STRESS_SCORE: "recovery",
- BLOOD_GLUCOSE: "glucose",
- // The PHQ-9 / GAD-7 / WHO-5 / SCI screener TOTALS ride the mental-health
- // module as well as the `mentalHealthScreeners` section key above. Item-level
- // answers are never a Measurement, so they can never leak through this path
- // regardless; the total is the intended clinical artefact (total + band per
- // clinical-instruments.md §4).
- //
- // The module gate used to be the ONLY gate, on the argument that
- // the module was opt-in and therefore default-OFF. It has been default-ON
- // since v1.29.1, so that argument stopped holding and depression / anxiety
- // screening totals reached FHIR bundles, the MCP doctor-visit resource and
- // clinician share links on accounts that never chose to send them. The
- // section key restores the choice; the module gate stays as the second lock.
- PHQ9_SCORE: "mentalHealth",
- GAD7_SCORE: "mentalHealth",
- WHO5_SCORE: "mentalHealth",
- SCI_SCORE: "mentalHealth",
-};
-
-export function filterMeasurementKeys(
- map: Record,
- sections: DoctorReportPrefs,
- moduleMap: Record,
-): Record {
- const out: Record = {};
- for (const [type, value] of Object.entries(map)) {
- const sectionKey = MEASUREMENT_TYPE_SECTION[type];
- if (sectionKey && sections[sectionKey] === false) continue;
- const moduleKey = MEASUREMENT_TYPE_MODULE[type];
- if (moduleKey && moduleMap[moduleKey] === false) continue;
- out[type] = value;
- }
- return out;
-}
-
-/**
- * The measurement types to exclude at QUERY time for a resolved selection.
- *
- * v1.28.25 pushed the gates into the SQL because rows of a deselected section
- * were fetched only to be dropped by {@link filterMeasurementKeys} — pure dead
- * weight, and the heaviest series (minute-grain CGM) is exactly the one that
- * runs to six figures over a long window. This is output-identical by
- * construction because it reads the same two maps the filter reads.
- *
- * WEIGHT is deliberately NEVER excluded: the BMI figure (gated by
- * `sections.bmi`, not `sections.weight`) and the GLP-1 weight delta both read
- * the PRE-filter maps, so weight rows must be present even when the weight
- * section itself is off. No other gated type is read before the filter — each
- * remaining pre-filter read (sleep nights, the glucose panel, the recovery
- * summary) sits behind the same gate that drives its exclusion here.
- */
-export function excludedMeasurementTypes(
- sections: DoctorReportPrefs,
- moduleMap: Record,
-): MeasurementType[] {
- const excluded: MeasurementType[] = [];
- for (const [type, sectionKey] of Object.entries(MEASUREMENT_TYPE_SECTION)) {
- if (type === WEIGHT_PREFILTER_EXCEPTION) continue;
- if (sections[sectionKey] === false) excluded.push(type as MeasurementType);
- }
- // Glucose carries a section toggle but no entry in the type→section map: the
- // raw series is gated here and the panel it feeds collapses separately.
- if (sections.glucose === false) excluded.push("BLOOD_GLUCOSE");
- for (const [type, moduleKey] of Object.entries(MEASUREMENT_TYPE_MODULE)) {
- if (moduleMap[moduleKey] === false) excluded.push(type as MeasurementType);
- }
- return excluded;
-}
-
-/** The one type the query gate must keep even when its section is off. */
-const WEIGHT_PREFILTER_EXCEPTION = "WEIGHT";
diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts
index af5ea2204..9adf02d25 100644
--- a/src/lib/doctor-report-types.ts
+++ b/src/lib/doctor-report-types.ts
@@ -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;
/**
* 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;
}
diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts
new file mode 100644
index 000000000..4142317f4
--- /dev/null
+++ b/src/lib/doctor-report/clinical-records.ts
@@ -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 {
+ 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[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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts
new file mode 100644
index 000000000..fb0b0dcee
--- /dev/null
+++ b/src/lib/doctor-report/collect.ts
@@ -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 {
+ 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();
+ 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 = {};
+ 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 =
+ glucosePanelOn && denseGlucose ? { ...denseSummary.glucoseStats } : {};
+ const glucoseRanges: Record = {};
+ 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,
+ };
+}
diff --git a/src/lib/doctor-report/dense-buckets.ts b/src/lib/doctor-report/dense-buckets.ts
new file mode 100644
index 000000000..597b55349
--- /dev/null
+++ b/src/lib/doctor-report/dense-buckets.ts
@@ -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 {
+ const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
+ if (!densePulse && !denseGlucose) return [];
+ return prisma.$queryRaw`
+ 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
+
+`;
+}
diff --git a/src/lib/doctor-report/glucose-panel.ts b/src/lib/doctor-report/glucose-panel.ts
new file mode 100644
index 000000000..ee417fee2
--- /dev/null
+++ b/src/lib/doctor-report/glucose-panel.ts
@@ -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,
+ };
+}
diff --git a/src/lib/doctor-report/measurement-series.ts b/src/lib/doctor-report/measurement-series.ts
new file mode 100644
index 000000000..5fdb12474
--- /dev/null
+++ b/src/lib/doctor-report/measurement-series.ts
@@ -0,0 +1,161 @@
+/**
+ * Per-type measurement series and their summary statistics.
+ *
+ * Folds the pre-aggregated dense buckets down to the same shape the raw-row
+ * path produces — one entry per local day per type, plus per-type and
+ * per-glucose-context stats — so the rest of the aggregator never has to know
+ * which path a type came down.
+ *
+ * Split out of the aggregator with the selection rework.
+ */
+import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
+import type { DoctorReportStats } from "@/lib/doctor-report-types";
+import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
+import type { DenseMeasurementBucket } from "./dense-buckets";
+import { glucoseClinicalFromBuckets } from "./glucose-panel";
+
+export interface DenseMeasurementSummary {
+ byType: Record>;
+ stats: Record;
+ glucoseStats: Record;
+ glucoseClinical: GlucoseClinicalMetrics;
+}
+
+interface DenseStatsAccumulator {
+ count: number;
+ sum: number;
+ min: number;
+ max: number;
+ latestAt: Date;
+ latest: number;
+}
+
+export function summariseDenseBuckets(
+ rows: readonly DenseMeasurementBucket[],
+ timezone: string,
+ sourcePriorityJson: unknown,
+ windowDays: number,
+): DenseMeasurementSummary {
+ const canonical = collapseMeasurementsToCanonical(
+ rows.map((row) => ({
+ ...row,
+ value: row.sumValue / row.count,
+ measuredAt: row.bucketStart,
+ })),
+ timezone,
+ sourcePriorityJson,
+ );
+ const byType: DenseMeasurementSummary["byType"] = {};
+ const statsAcc = new Map();
+ const glucoseStatsAcc = new Map();
+
+ const byTypeDay = new Map<
+ string,
+ {
+ type: string;
+ bucketStart: Date;
+ count: number;
+ sum: number;
+ min: number;
+ max: number;
+ latestAt: Date;
+ latest: number;
+ }
+ >();
+ for (const row of canonical) {
+ const key = `${row.type}:${row.bucketStart.toISOString()}`;
+ const current = byTypeDay.get(key);
+ if (!current) {
+ byTypeDay.set(key, {
+ type: row.type,
+ bucketStart: row.bucketStart,
+ count: row.count,
+ sum: row.sumValue,
+ min: row.minValue,
+ max: row.maxValue,
+ latestAt: row.latestAt,
+ latest: row.latestValue,
+ });
+ continue;
+ }
+ current.count += row.count;
+ current.sum += row.sumValue;
+ current.min = Math.min(current.min, row.minValue);
+ current.max = Math.max(current.max, row.maxValue);
+ if (row.latestAt > current.latestAt) {
+ current.latestAt = row.latestAt;
+ current.latest = row.latestValue;
+ }
+ }
+
+ const accumulate = (
+ target: Map,
+ key: string,
+ row: DenseStatsAccumulator,
+ ) => {
+ const current = target.get(key);
+ if (!current) {
+ target.set(key, { ...row });
+ return;
+ }
+ current.count += row.count;
+ current.sum += row.sum;
+ current.min = Math.min(current.min, row.min);
+ current.max = Math.max(current.max, row.max);
+ if (row.latestAt > current.latestAt) {
+ current.latestAt = row.latestAt;
+ current.latest = row.latest;
+ }
+ };
+
+ for (const row of byTypeDay.values()) {
+ (byType[row.type] ??= []).push({
+ value: row.sum / row.count,
+ measuredAt: row.bucketStart.toISOString(),
+ });
+ accumulate(statsAcc, row.type, row);
+ }
+
+ for (const row of canonical) {
+ if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
+ accumulate(glucoseStatsAcc, row.glucoseContext, {
+ count: row.count,
+ sum: row.sumValue,
+ min: row.minValue,
+ max: row.maxValue,
+ latestAt: row.latestAt,
+ latest: row.latestValue,
+ });
+ }
+
+ for (const entries of Object.values(byType)) {
+ entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
+ }
+ const stats: DenseMeasurementSummary["stats"] = {};
+ for (const [key, value] of statsAcc) {
+ stats[key] = {
+ count: value.count,
+ avg: value.sum / value.count,
+ min: value.min,
+ max: value.max,
+ latest: value.latest,
+ };
+ }
+ const glucoseStats: DenseMeasurementSummary["glucoseStats"] = {};
+ for (const [key, value] of glucoseStatsAcc) {
+ glucoseStats[key] = {
+ count: value.count,
+ avg: value.sum / value.count,
+ min: value.min,
+ max: value.max,
+ latest: value.latest,
+ };
+ }
+
+ return {
+ byType,
+ stats,
+ glucoseStats,
+ glucoseClinical: glucoseClinicalFromBuckets(canonical, windowDays),
+ };
+}
diff --git a/src/lib/doctor-report/medications.ts b/src/lib/doctor-report/medications.ts
new file mode 100644
index 000000000..517a0b650
--- /dev/null
+++ b/src/lib/doctor-report/medications.ts
@@ -0,0 +1,259 @@
+/**
+ * The medication half of the doctor report: the per-dose administration
+ * ledger and the GLP-1 therapy block.
+ *
+ * Four leaves cover this domain and each one is a separate release decision:
+ * the drug list, the administration ledger, the adherence figures, and the
+ * GLP-1 block. A clinician usually wants the list and the adherence rate; the
+ * ledger is every logged dose and the GLP-1 block carries a dose history and
+ * side-effect tallies, which is a different conversation.
+ *
+ * Split out of the aggregator with the selection rework.
+ */
+import { readNote } from "@/lib/crypto/note-cipher";
+import type {
+ DoctorReportCompliance,
+ DoctorReportData,
+} from "@/lib/doctor-report-types";
+import { resolveMaxMedicationAdministrations } from "@/lib/doctor-report-helpers";
+
+/** The medication columns the ledger and the GLP-1 block read. */
+export interface MedicationRowForReport {
+ id: string;
+ name: string;
+ dose: string | null;
+ treatmentClass: string | null;
+ doseChanges: Array<{
+ effectiveFrom: Date;
+ doseValue: number;
+ doseUnit: string;
+ note: string | null;
+ noteEncrypted: Uint8Array | null;
+ }>;
+ intakeEvents: Array<{ takenAt: Date | null; injectionSite: string | null }>;
+}
+
+/** The intake columns the administration ledger reads. */
+export interface IntakeEventRowForReport {
+ medicationId: string;
+ scheduledFor: Date;
+ takenAt: Date | null;
+ skipped: boolean;
+ injectionSite: string | null;
+ medication: {
+ id: string;
+ name: string;
+ dose: string | null;
+ atcCode: string | null;
+ rxNormCode: string | null;
+ deliveryForm: string | null;
+ };
+}
+
+export interface AdministrationLedger {
+ administrations: NonNullable;
+ truncation: DoctorReportData["medicationAdministrationsTruncation"];
+}
+
+/**
+ * One entry per ACTED intake — a taken dose (completed) or an explicit skip
+ * (not-done). A scheduled-but-unconfirmed slot is not an administration event
+ * and is omitted entirely, so the export never asserts an administration that
+ * did not happen.
+ *
+ * The structured dose is resolved from the medication's dose-change history —
+ * the latest change effective at or before the administration instant — when
+ * one exists. A skip records no dose consumed.
+ */
+export function buildAdministrationLedger(
+ medications: readonly MedicationRowForReport[],
+ intakeEvents: readonly IntakeEventRowForReport[],
+): AdministrationLedger {
+ const doseChangesByMedId = new Map<
+ string,
+ Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
+ >();
+ for (const m of medications) {
+ doseChangesByMedId.set(
+ m.id,
+ m.doseChanges.map((dc) => ({
+ effectiveFrom: dc.effectiveFrom,
+ doseValue: dc.doseValue,
+ doseUnit: dc.doseUnit,
+ })),
+ );
+ }
+
+ const resolveDoseInEffect = (
+ medicationId: string,
+ at: Date,
+ ): { value: number; unit: string } | null => {
+ const changes = doseChangesByMedId.get(medicationId);
+ if (!changes || changes.length === 0) return null;
+ // Loaded ordered by `effectiveFrom asc`; take the last one whose
+ // `effectiveFrom` is at or before the administration instant.
+ let inEffect: { value: number; unit: string } | null = null;
+ for (const c of changes) {
+ if (c.effectiveFrom.getTime() <= at.getTime()) {
+ inEffect = { value: c.doseValue, unit: c.doseUnit };
+ } else {
+ break;
+ }
+ }
+ return inEffect;
+ };
+
+ const all: NonNullable = [];
+ for (const event of intakeEvents) {
+ const isTaken = event.takenAt !== null;
+ if (!isTaken && !event.skipped) continue;
+ const effectiveAt = isTaken ? (event.takenAt as Date) : event.scheduledFor;
+ all.push({
+ medicationName: event.medication.name,
+ effectiveAt: effectiveAt.toISOString(),
+ status: isTaken ? "completed" : "not-done",
+ doseText: event.medication.dose || null,
+ dose: isTaken
+ ? resolveDoseInEffect(event.medication.id, effectiveAt)
+ : null,
+ injectionSite: event.injectionSite ?? null,
+ atcCode: event.medication.atcCode ?? null,
+ rxNormCode: event.medication.rxNormCode ?? null,
+ deliveryForm: event.medication.deliveryForm ?? null,
+ });
+ }
+
+ // `intakeEvents` is ordered `scheduledFor: asc`, so the most-recent acted
+ // rows are at the tail; keep the last N and flag the trim so the narrative
+ // can disclose it. The omitted rows are the OLDEST in the window — recent
+ // adherence is preserved intact. The cap is a coarse safety ceiling against
+ // a pathological multi-year, many-medication export.
+ const cap = resolveMaxMedicationAdministrations(
+ process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS,
+ );
+ const total = all.length;
+ const truncated = total > cap;
+ const administrations = truncated ? all.slice(total - cap) : all;
+ return {
+ administrations,
+ truncation: truncated ? { total, included: administrations.length } : null,
+ };
+}
+
+/**
+ * Mood tags that mark a GLP-1 side effect, in both languages the tag
+ * vocabulary ships. A tag not on this list is never counted, so a free-text
+ * tag cannot leak into the block as an invented side effect.
+ */
+const SIDE_EFFECT_TAGS = new Set([
+ "nausea",
+ "constipation",
+ "diarrhea",
+ "fatigue",
+ "appetite-loss",
+ "heartburn",
+ "headache",
+ "übelkeit",
+ "verstopfung",
+ "durchfall",
+ "müdigkeit",
+ "appetitlosigkeit",
+ "sodbrennen",
+ "kopfschmerzen",
+]);
+
+function parseTags(raw: string): string[] {
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed)
+ ? parsed
+ .map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
+ .filter(Boolean)
+ : [];
+ } catch {
+ return raw
+ .split(",")
+ .map((p) => p.trim())
+ .filter(Boolean);
+ }
+}
+
+/**
+ * The GLP-1 therapy block: current dose, dose history, last injection, the
+ * weight delta over the window, and side-effect tallies.
+ *
+ * `moodTagRows` is empty unless the MOOD leaf was selected — the aggregator
+ * does not read mood entries otherwise — so the side-effect tally collapses to
+ * nothing rather than being computed from data the owner withheld.
+ */
+export function buildGlp1Block(params: {
+ medications: readonly MedicationRowForReport[];
+ compliance: Record;
+ weightSeries: ReadonlyArray<{ value: number; measuredAt: string }>;
+ moodTagRows: ReadonlyArray<{ tags: string | null }>;
+}): DoctorReportData["glp1"] {
+ const glp1Meds = params.medications.filter(
+ (m) => m.treatmentClass === "GLP1",
+ );
+ if (glp1Meds.length === 0) return null;
+
+ const weightStartKg = params.weightSeries[0]?.value ?? null;
+ const weightEndKg =
+ params.weightSeries[params.weightSeries.length - 1]?.value ?? null;
+ const weightDeltaKg =
+ weightStartKg !== null && weightEndKg !== null
+ ? Math.round((weightEndKg - weightStartKg) * 10) / 10
+ : null;
+
+ const sideEffectCounts = new Map();
+ for (const row of params.moodTagRows) {
+ for (const tag of parseTags(row.tags ?? "")) {
+ const lower = tag.toLowerCase();
+ if (!SIDE_EFFECT_TAGS.has(lower)) continue;
+ sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
+ }
+ }
+
+ return {
+ medications: glp1Meds.map((m) => {
+ const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
+ const lastIntake = m.intakeEvents[0] ?? null;
+ const comp = params.compliance[m.name] ?? {
+ taken: 0,
+ total: 0,
+ skipped: 0,
+ missed: 0,
+ };
+ return {
+ name: m.name,
+ currentDose: latest
+ ? {
+ value: latest.doseValue,
+ unit: latest.doseUnit,
+ since: latest.effectiveFrom.toISOString(),
+ }
+ : null,
+ doseHistory: m.doseChanges.map((dc) => ({
+ value: dc.doseValue,
+ unit: dc.doseUnit,
+ effectiveFrom: dc.effectiveFrom.toISOString(),
+ note: readNote(dc.noteEncrypted, dc.note),
+ })),
+ lastInjection:
+ lastIntake && lastIntake.takenAt
+ ? {
+ date: lastIntake.takenAt.toISOString(),
+ site: lastIntake.injectionSite,
+ }
+ : null,
+ compliance: { taken: comp.taken, total: comp.total },
+ };
+ }),
+ weightStartKg,
+ weightEndKg,
+ weightDeltaKg,
+ sideEffects: Array.from(sideEffectCounts.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map(([tag, count]) => ({ tag, count })),
+ };
+}
diff --git a/src/lib/doctor-report/selection-gate.ts b/src/lib/doctor-report/selection-gate.ts
new file mode 100644
index 000000000..4b72734f5
--- /dev/null
+++ b/src/lib/doctor-report/selection-gate.ts
@@ -0,0 +1,103 @@
+/**
+ * The one place a doctor-report leaf is admitted or refused.
+ *
+ * Two independent gates, and both must pass:
+ *
+ * - the per-artefact SELECTION the owner made for this report, and
+ * - the app-wide MODULE switch that decides whether the domain exists here
+ * at all.
+ *
+ * A disabled module wins over a selected leaf; a deselected leaf wins over an
+ * enabled module. Neither softens the other.
+ *
+ * The gate is fail-closed in both directions. A leaf the selection does not
+ * carry is excluded, because membership is inclusion and there is no default
+ * to consult. A measurement type the catalogue does not know is also excluded
+ * — and annotated, so a drifted enum shows up in the wide-event stream instead
+ * of riding out on a clinical document. The old filter passed such a type
+ * through, which is what made "we forgot to map it" the same as "the user
+ * asked for it".
+ */
+import type { ModuleKey } from "@/lib/modules/gate";
+import { annotate } from "@/lib/logging/context";
+import {
+ LEAF_MODULE,
+ isReportLeafId,
+ type ReportLeafId,
+} from "@/lib/report-selection/catalogue";
+import type { ReportSelection } from "@/lib/report-selection/selection";
+import type { MeasurementType } from "@/generated/prisma/client";
+
+/** A resolved gate: one question, asked the same way everywhere. */
+export interface ReportGate {
+ /** Whether this artefact carries the leaf. */
+ admits(leaf: ReportLeafId): boolean;
+ /** The leaves that survived both gates, in catalogue order. */
+ readonly admitted: readonly ReportLeafId[];
+}
+
+export function resolveReportGate(
+ selection: ReportSelection,
+ moduleMap: Record,
+): ReportGate {
+ const admits = (leaf: ReportLeafId): boolean => {
+ if (!selection.has(leaf)) return false;
+ const moduleKey = LEAF_MODULE[leaf];
+ if (moduleKey && moduleMap[moduleKey] === false) return false;
+ return true;
+ };
+ return { admits, admitted: selection.leaves.filter(admits) };
+}
+
+/**
+ * The measurement types to exclude at QUERY time.
+ *
+ * Rows of a refused leaf are not fetched only to be dropped — the heaviest
+ * series (minute-grain CGM, per-sample heart rate) is exactly the one that
+ * runs to six figures over a long window, and a leaf the owner withheld should
+ * not enter this process's memory at all.
+ *
+ * `keepForDerived` names the types the aggregator reads BEFORE the output
+ * filter and therefore must not exclude even when the leaf itself is refused:
+ * the BMI figure and the GLP-1 weight delta are both computed from weight rows
+ * and are gated by their own leaves.
+ */
+export function excludedMeasurementTypes(
+ gate: ReportGate,
+ allTypes: readonly MeasurementType[],
+ keepForDerived: ReadonlySet,
+): MeasurementType[] {
+ return allTypes.filter(
+ (type) => !gate.admits(type) && !keepForDerived.has(type),
+ );
+}
+
+/**
+ * Strip every measurement type the gate refuses from a type-keyed map.
+ *
+ * This is the output half of the same decision the query gate makes, reading
+ * the same gate object, so the two can never disagree. A type that is not a
+ * catalogue leaf at all is excluded and annotated rather than passed through.
+ */
+export function filterMeasurementKeys(
+ map: Record,
+ gate: ReportGate,
+): Record {
+ const out: Record = {};
+ const unmapped = new Set();
+ for (const [type, value] of Object.entries(map)) {
+ if (!isReportLeafId(type)) {
+ // Meta, not `action` — the wide event carries one action name and it
+ // belongs to the route that is serving the request. The key is stable so
+ // a dashboard can alert on it.
+ unmapped.add(type);
+ continue;
+ }
+ if (!gate.admits(type)) continue;
+ out[type] = value;
+ }
+ if (unmapped.size > 0) {
+ annotate({ meta: { reportUnmappedLeaves: [...unmapped].sort() } });
+ }
+ return out;
+}
diff --git a/src/lib/doctor-report/type-label-keys.ts b/src/lib/doctor-report/type-label-keys.ts
deleted file mode 100644
index 9123e57b9..000000000
--- a/src/lib/doctor-report/type-label-keys.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Per-vital i18n label keys for the doctor report.
- *
- * Extracted from `doctor-report-pdf-core` so consumers that only need the
- * label map (e.g. the public clinician share view, a pure server component)
- * do not pull the jsPDF renderer into their module graph. The PDF core
- * re-exports this map so there is a single source of truth.
- *
- * Exported for coverage tests (issue #109 / phase P0) so a future enum
- * addition is caught by a unit test rather than reaching production as a raw
- * enum string in the PDF.
- */
-export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = {
- WEIGHT: "doctorReport.typeWeight",
- BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
- BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
- PULSE: "doctorReport.typePulse",
- BODY_FAT: "doctorReport.typeBodyFat",
- SLEEP_DURATION: "doctorReport.typeSleep",
- ACTIVITY_STEPS: "doctorReport.typeSteps",
- TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
- BONE_MASS: "doctorReport.typeBoneMass",
- OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
-};
diff --git a/src/lib/export/health-record-artefacts.ts b/src/lib/export/health-record-artefacts.ts
new file mode 100644
index 000000000..3fc625d32
--- /dev/null
+++ b/src/lib/export/health-record-artefacts.ts
@@ -0,0 +1,76 @@
+/**
+ * The three health-record artefacts, produced from one aggregated payload.
+ *
+ * Moved out of `POST /api/export/health-record` so the route keeps the HTTP
+ * concerns — auth, module gate, rate limit, validation, audit — and the
+ * rendering keeps its own. All three read the SAME `DoctorReportData`, which
+ * is what makes the PDF, the bundle and the zip describe an identical record
+ * by construction rather than by three renderers each remembering the scope.
+ */
+import { zipSync, strToU8 } from "fflate";
+
+import type { DoctorReportData } from "@/lib/doctor-report-data";
+import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
+import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
+import type { FhirRecordInputs } from "@/lib/fhir/build-bundle";
+import type { Locale } from "@/lib/i18n/config";
+import type { TimeFormatPreference } from "@/lib/format-locale";
+
+export interface ArtefactInputs {
+ data: DoctorReportData;
+ /** Decrypted KVNR, or null when the insurance leaf was not selected. */
+ insuranceNumber: string | null;
+ records: FhirRecordInputs;
+ germanAtc: boolean;
+ includeCharts: boolean;
+ locale: Locale;
+ userTz: string;
+ timeFormat: TimeFormatPreference;
+ t: (key: string, vars?: Record) => string;
+}
+
+/** The FHIR R4 document Bundle, serialised. */
+export function buildBundleJson(input: ArtefactInputs): string {
+ const bundle = buildFhirDocumentBundle(
+ input.data,
+ { insuranceNumber: input.insuranceNumber },
+ undefined,
+ { germanAtc: input.germanAtc },
+ input.records,
+ );
+ return JSON.stringify(bundle);
+}
+
+/** The rendered clinical PDF. */
+export function buildPdfBytes(input: ArtefactInputs): Uint8Array {
+ return renderDoctorReportPdfBytes(input.data, {
+ t: input.t,
+ locale: input.locale,
+ userTz: input.userTz,
+ timeFormat: input.timeFormat,
+ insuranceNumber: input.insuranceNumber,
+ includeCharts: input.includeCharts,
+ });
+}
+
+/** One zip holding the PDF, the Bundle and a README. */
+export function buildPackageZip(
+ input: ArtefactInputs,
+ pdfBytes: Uint8Array,
+): Uint8Array {
+ const bundle = buildFhirDocumentBundle(
+ input.data,
+ { insuranceNumber: input.insuranceNumber },
+ undefined,
+ { germanAtc: input.germanAtc },
+ input.records,
+ );
+ return zipSync(
+ {
+ "report.pdf": pdfBytes,
+ "bundle.json": strToU8(JSON.stringify(bundle, null, 2)),
+ "README.txt": strToU8(input.t("doctorReport.packageReadme")),
+ },
+ { level: 6 },
+ );
+}
diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts
index 105b9be36..1ac52dd8e 100644
--- a/src/lib/mcp/prompts.ts
+++ b/src/lib/mcp/prompts.ts
@@ -23,6 +23,7 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import { summariseForVisit } from "./doctor-visit-summary";
import { coachScopeWindowSchema } from "@/lib/ai/coach/types";
@@ -305,11 +306,17 @@ export const MCP_PROMPTS: McpPromptDefinition[] = [
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
- const data = await collectDoctorReportData(ctx.userId, {
- start,
- end,
- days,
- });
+ // The assistant cannot ask a human, so this replays the owner's SAVED
+ // selection — the same object reviewed in Settings. An account that never
+ // saved one resolves to the empty selection and this surface says so,
+ // rather than inventing a scope. It used to assemble the full record at
+ // server defaults, on the one wire that egresses to a third party.
+ const selection = await resolveSavedSelection(ctx.userId);
+ const data = await collectDoctorReportData(
+ ctx.userId,
+ { start, end, days },
+ selection,
+ );
const summary = summariseForVisit(data);
annotate({
diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts
index 6fcb8b361..2d3ab6e80 100644
--- a/src/lib/mcp/resources.ts
+++ b/src/lib/mcp/resources.ts
@@ -34,6 +34,7 @@ import {
BIOMARKER_PANELS,
} from "@/lib/labs/biomarker-catalog";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { resolveSavedSelection } from "@/lib/report-selection/saved-profile";
import { isModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CODES, type NutrientCode } from "@/lib/nutrients/catalog";
import { getNutrients, NUTRIENT_LABELS } from "@/lib/mcp/nutrients-read";
@@ -306,7 +307,17 @@ async function readDoctorVisit(
const days = WINDOW_DAYS[window] ?? 90;
const end = new Date();
const start = new Date(end.getTime() - days * MS_PER_DAY);
- const data = await collectDoctorReportData(ctx.userId, { start, end, days });
+ // The assistant cannot ask a human, so this replays the owner's SAVED
+ // selection — the same object reviewed in Settings. An account that never
+ // saved one resolves to the empty selection and this surface says so,
+ // rather than inventing a scope. It used to assemble the full record at
+ // server defaults, on the one wire that egresses to a third party.
+ const selection = await resolveSavedSelection(ctx.userId);
+ const data = await collectDoctorReportData(
+ ctx.userId,
+ { start, end, days },
+ selection,
+ );
annotate({
action: { name: "mcp.resource.read" },
meta: { resource: "doctor-visit-report", days },
diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts
new file mode 100644
index 000000000..32da24148
--- /dev/null
+++ b/src/lib/measurements/type-label-keys.ts
@@ -0,0 +1,110 @@
+/**
+ * Per-measurement-type i18n label keys.
+ *
+ * Extracted from `measurement-list-meta.ts` so consumers that only need the
+ * label map — the report selection catalogue, the jsPDF renderer, the public
+ * clinician share view, any server component — do not pull the lucide icon
+ * components into their module graph. The original module re-exports this map,
+ * so there is one source of truth and no import site had to change.
+ *
+ * The `Record` annotation is the guarantee: a new
+ * enum member without a label fails `pnpm typecheck` rather than reaching a
+ * surface as a raw enum string.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+
+export const MEASUREMENT_TYPE_LABEL_KEYS: Record = {
+ WEIGHT: "measurements.typeWeight",
+ BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
+ BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
+ PULSE: "measurements.typePulse",
+ BODY_FAT: "measurements.typeBodyFat",
+ SLEEP_DURATION: "measurements.typeSleep",
+ ACTIVITY_STEPS: "measurements.typeSteps",
+ BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
+ TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
+ BONE_MASS: "measurements.typeBoneMass",
+ OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
+ // ── v1.4.23 Apple Health additions ──
+ HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
+ RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
+ ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
+ FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
+ WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
+ VO2_MAX: "measurements.typeVo2Max",
+ BODY_TEMPERATURE: "measurements.typeBodyTemperature",
+ // ── v1.4.25 W5d Withings full coverage ──
+ FAT_FREE_MASS: "measurements.typeFatFreeMass",
+ FAT_MASS: "measurements.typeFatMass",
+ MUSCLE_MASS: "measurements.typeMuscleMass",
+ SKIN_TEMPERATURE: "measurements.typeSkinTemperature",
+ PULSE_WAVE_VELOCITY: "measurements.typePulseWaveVelocity",
+ VASCULAR_AGE: "measurements.typeVascularAge",
+ VISCERAL_FAT: "measurements.typeVisceralFat",
+ // ── v1.4.25 W8d Apple Health server-prep ──
+ AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv",
+ AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone",
+ TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight",
+ // ── v1.4.30 R-F T1.4 + T1.5 ──
+ WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
+ AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
+ // ── v1.5.5 iOS-coord additions ──
+ RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
+ BODY_MASS_INDEX: "measurements.typeBodyMassIndex",
+ LEAN_BODY_MASS: "measurements.typeLeanBodyMass",
+ WALKING_HEART_RATE_AVERAGE: "measurements.typeWalkingHeartRateAverage",
+ WALKING_ASYMMETRY: "measurements.typeWalkingAsymmetry",
+ WALKING_DOUBLE_SUPPORT: "measurements.typeWalkingDoubleSupport",
+ // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
+ WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength",
+ WALKING_SPEED: "measurements.typeWalkingSpeed",
+ // ── v1.10.0 — additive HealthKit signals (WX-A) ──
+ CARDIO_RECOVERY: "measurements.typeCardioRecovery",
+ WRIST_TEMPERATURE: "measurements.typeWristTemperature",
+ FALL_COUNT: "measurements.typeFallCount",
+ SIX_MINUTE_WALK_DISTANCE: "measurements.typeSixMinuteWalkDistance",
+ STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed",
+ STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed",
+ BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances",
+ // ── v1.10.0 — categorical events (WX-B) ──
+ IRREGULAR_RHYTHM_NOTIFICATION: "measurements.typeIrregularRhythmNotification",
+ HIGH_HEART_RATE_EVENT: "measurements.typeHighHeartRateEvent",
+ LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent",
+ WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent",
+ BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent",
+ // ── v1.10.0 — computed scores (WX-C) ──
+ RECOVERY_SCORE: "measurements.typeRecoveryScore",
+ STRESS_SCORE: "measurements.typeStressScore",
+ STRAIN_SCORE: "measurements.typeStrainScore",
+ // ── v1.11.0 — WHOOP-native score classes ──
+ HRV_RMSSD: "measurements.typeHrvRmssd",
+ DAY_STRAIN: "measurements.typeDayStrain",
+ WORKOUT_STRAIN: "measurements.typeWorkoutStrain",
+ SLEEP_PERFORMANCE: "measurements.typeSleepPerformance",
+ SLEEP_EFFICIENCY: "measurements.typeSleepEfficiency",
+ SLEEP_CONSISTENCY: "measurements.typeSleepConsistency",
+ SLEEP_NEED: "measurements.typeSleepNeed",
+ ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj",
+ // ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
+ AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate",
+ MAX_HEART_RATE: "measurements.typeMaxHeartRate",
+ SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount",
+ // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
+ ANS_CHARGE: "measurements.typeAnsCharge",
+ CARDIO_LOAD: "measurements.typeCardioLoad",
+ // ── v1.17.1 — Oura coverage completion ──
+ SLEEP_SCORE: "measurements.typeSleepScore",
+ BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
+ // ── v1.19.0 — Oura resilience ──
+ RESILIENCE: "measurements.typeResilience",
+ // ── v1.25 — clinical-signals wave ──
+ PHQ9_SCORE: "measurements.typePhq9Score",
+ GAD7_SCORE: "measurements.typeGad7Score",
+ // ── v1.27.9 — screening scores ──
+ WHO5_SCORE: "measurements.typeWho5Score",
+ SCI_SCORE: "measurements.typeSciScore",
+ GRIP_STRENGTH: "measurements.typeGripStrength",
+ PAIN_NRS: "measurements.typePainNrs",
+ WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
+ WAIST_TO_HEIGHT: "measurements.typeWaistToHeight",
+};
diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts
index 3fd9ef2fe..7ce36fdef 100644
--- a/src/lib/openapi/routes/meta.ts
+++ b/src/lib/openapi/routes/meta.ts
@@ -101,14 +101,25 @@ const capabilitiesResponse = z
.describe(
"Maximum lifetime of a share link, in days. No never-expiring share.",
),
- resourceTypes: z
+ reportDownload: z
.array(z.string())
- .describe("FHIR resource types a share link may be scoped to serve."),
- sections: z
+ .describe(
+ "Machine formats a share link serves as a download under its token, behind the same unlock as the page.",
+ ),
+ selectionVersion: z
+ .number()
+ .int()
+ .describe("Version of the leaf-selection grammar this build speaks."),
+ groups: z
.array(z.string())
- .describe("Scopeable report sections a share link may toggle."),
+ .describe("Selection group ids, in presentation order."),
+ leaves: z
+ .array(z.string())
+ .describe(
+ "Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion.",
+ ),
})
- .describe("Clinician share-link surface descriptor (v1.11)."),
+ .describe("Clinician share-link surface descriptor."),
})
.meta({
id: "CapabilitiesResponse",
diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts
new file mode 100644
index 000000000..74a3a562e
--- /dev/null
+++ b/src/lib/report-selection/catalogue.ts
@@ -0,0 +1,380 @@
+/**
+ * The doctor-report leaf catalogue: every piece of the record a user can
+ * release or withhold, and the group it is presented under.
+ *
+ * A LEAF is the smallest releasable unit. There are exactly two kinds: every
+ * `MeasurementType` enum member, and the structured sections of the report
+ * payload that are not measurement series (identity, insurance, the glucose
+ * panel, labs, the three medication blocks, GLP-1, allergies, illness
+ * episodes, family history, mood, cycle).
+ *
+ * One catalogue drives the selection panel, the aggregator, the PDF, the
+ * clinician share view and the guard tests, so a control can never again exist
+ * without a gating path behind it, and a gate can never exist without a
+ * control.
+ *
+ * Two of the three new-metric locks live here:
+ *
+ * - `MEASUREMENT_LEAF_GROUP` is a `Record`.
+ * Adding an enum member without deciding its group fails `pnpm typecheck`,
+ * which runs in the pre-commit hook and in CI. The decision has to be made
+ * in the same commit as the enum member, and it has no effect on anyone's
+ * saved selection.
+ * - `STRUCTURED_LEAF_GROUP` does the same over the closed structured union.
+ *
+ * The third lock is set membership: a stored selection is a list of what was
+ * chosen, so a leaf that did not exist when it was saved is not in it. See
+ * `./selection.ts`.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import type { ModuleKey } from "@/lib/modules/registry";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
+
+/**
+ * The structured (non-measurement) leaves. A closed union, so
+ * `STRUCTURED_LEAF_GROUP` below is exhaustive by the compiler rather than by
+ * review.
+ */
+export type StructuredLeafId =
+ | "PATIENT_IDENTITY"
+ | "INSURANCE"
+ | "GLUCOSE_PANEL"
+ | "LAB_RESULTS"
+ | "MEDICATION_LIST"
+ | "MEDICATION_ADMINISTRATIONS"
+ | "MEDICATION_COMPLIANCE"
+ | "GLP1_THERAPY"
+ | "ALLERGIES"
+ | "ILLNESS_EPISODES"
+ | "FAMILY_HISTORY"
+ | "MOOD"
+ | "CYCLE";
+
+/** Every leaf id: the 77 measurement types plus the 13 structured sections. */
+export type ReportLeafId = MeasurementType | StructuredLeafId;
+
+/** The presentation groups, in the order the panel and the PDF render them. */
+export const REPORT_GROUP_ORDER = [
+ "identity",
+ "vitals",
+ "cardio",
+ "body",
+ "sleepRecovery",
+ "activity",
+ "mobility",
+ "environment",
+ "glucose",
+ "labs",
+ "medications",
+ "history",
+ "sensitive",
+] as const;
+
+export type ReportGroupId = (typeof REPORT_GROUP_ORDER)[number];
+
+/**
+ * The fenced tier. It renders below a separator with no group checkbox, so no
+ * single control can turn on more than one of these at a time, and it is never
+ * in the shipped template. Mental-health screening totals, mood and cycle are
+ * the user's own sensitive data; family history is third-party information
+ * about people who consented to nothing.
+ */
+export const SENSITIVE_GROUP_ID = "sensitive" satisfies ReportGroupId;
+
+/**
+ * Measurement leaf → group. Exhaustive over the enum by construction.
+ *
+ * Two placements carry a stated reason. `PAIN_NRS` sits in vitals because pain
+ * is read as a vital sign in every intake, not because it is a device reading.
+ * The four screening totals are fenced because a depression or anxiety
+ * instrument is not something to hand over as part of a bundle.
+ */
+export const MEASUREMENT_LEAF_GROUP: Record = {
+ // ── vitals ──
+ BLOOD_PRESSURE_SYS: "vitals",
+ BLOOD_PRESSURE_DIA: "vitals",
+ PULSE: "vitals",
+ OXYGEN_SATURATION: "vitals",
+ RESPIRATORY_RATE: "vitals",
+ BODY_TEMPERATURE: "vitals",
+ PAIN_NRS: "vitals",
+
+ // ── cardio ──
+ RESTING_HEART_RATE: "cardio",
+ HEART_RATE_VARIABILITY: "cardio",
+ HRV_RMSSD: "cardio",
+ VO2_MAX: "cardio",
+ WALKING_HEART_RATE_AVERAGE: "cardio",
+ AVERAGE_HEART_RATE: "cardio",
+ MAX_HEART_RATE: "cardio",
+ CARDIO_RECOVERY: "cardio",
+ CARDIO_LOAD: "cardio",
+ PULSE_WAVE_VELOCITY: "cardio",
+ VASCULAR_AGE: "cardio",
+ IRREGULAR_RHYTHM_NOTIFICATION: "cardio",
+ HIGH_HEART_RATE_EVENT: "cardio",
+ LOW_HEART_RATE_EVENT: "cardio",
+
+ // ── body ──
+ WEIGHT: "body",
+ BODY_MASS_INDEX: "body",
+ BODY_FAT: "body",
+ FAT_MASS: "body",
+ FAT_FREE_MASS: "body",
+ MUSCLE_MASS: "body",
+ LEAN_BODY_MASS: "body",
+ TOTAL_BODY_WATER: "body",
+ BONE_MASS: "body",
+ VISCERAL_FAT: "body",
+ WAIST_CIRCUMFERENCE: "body",
+ WAIST_TO_HEIGHT: "body",
+
+ // ── sleep & recovery ──
+ SLEEP_DURATION: "sleepRecovery",
+ SLEEP_SCORE: "sleepRecovery",
+ SLEEP_PERFORMANCE: "sleepRecovery",
+ SLEEP_EFFICIENCY: "sleepRecovery",
+ SLEEP_CONSISTENCY: "sleepRecovery",
+ SLEEP_NEED: "sleepRecovery",
+ SLEEP_DISTURBANCE_COUNT: "sleepRecovery",
+ BREATHING_DISTURBANCES: "sleepRecovery",
+ BREATHING_DISTURBANCE_EVENT: "sleepRecovery",
+ RECOVERY_SCORE: "sleepRecovery",
+ STRESS_SCORE: "sleepRecovery",
+ RESILIENCE: "sleepRecovery",
+ ANS_CHARGE: "sleepRecovery",
+ SKIN_TEMPERATURE: "sleepRecovery",
+ WRIST_TEMPERATURE: "sleepRecovery",
+ BODY_TEMPERATURE_DEVIATION: "sleepRecovery",
+
+ // ── activity ──
+ ACTIVITY_STEPS: "activity",
+ WALKING_RUNNING_DISTANCE: "activity",
+ FLIGHTS_CLIMBED: "activity",
+ ACTIVE_ENERGY_BURNED: "activity",
+ ENERGY_EXPENDITURE_KJ: "activity",
+ STRAIN_SCORE: "activity",
+ DAY_STRAIN: "activity",
+ WORKOUT_STRAIN: "activity",
+
+ // ── mobility & gait ──
+ WALKING_SPEED: "mobility",
+ WALKING_ASYMMETRY: "mobility",
+ WALKING_DOUBLE_SUPPORT: "mobility",
+ WALKING_STEP_LENGTH: "mobility",
+ WALKING_STEADINESS: "mobility",
+ WALKING_STEADINESS_EVENT: "mobility",
+ STAIR_ASCENT_SPEED: "mobility",
+ STAIR_DESCENT_SPEED: "mobility",
+ SIX_MINUTE_WALK_DISTANCE: "mobility",
+ FALL_COUNT: "mobility",
+ GRIP_STRENGTH: "mobility",
+
+ // ── environment ──
+ AUDIO_EXPOSURE_ENV: "environment",
+ AUDIO_EXPOSURE_HEADPHONE: "environment",
+ AUDIO_EXPOSURE_EVENT: "environment",
+ TIME_IN_DAYLIGHT: "environment",
+
+ // ── glucose ──
+ BLOOD_GLUCOSE: "glucose",
+
+ // ── fenced ──
+ PHQ9_SCORE: "sensitive",
+ GAD7_SCORE: "sensitive",
+ WHO5_SCORE: "sensitive",
+ SCI_SCORE: "sensitive",
+};
+
+/** Structured leaf → group. Exhaustive over the closed union. */
+export const STRUCTURED_LEAF_GROUP: Record = {
+ PATIENT_IDENTITY: "identity",
+ INSURANCE: "identity",
+ GLUCOSE_PANEL: "glucose",
+ LAB_RESULTS: "labs",
+ MEDICATION_LIST: "medications",
+ MEDICATION_ADMINISTRATIONS: "medications",
+ MEDICATION_COMPLIANCE: "medications",
+ GLP1_THERAPY: "medications",
+ ALLERGIES: "history",
+ ILLNESS_EPISODES: "history",
+ FAMILY_HISTORY: "sensitive",
+ MOOD: "sensitive",
+ CYCLE: "sensitive",
+};
+
+/** i18n label keys for the 13 structured leaves. */
+export const STRUCTURED_LEAF_LABEL_KEYS: Record = {
+ PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
+ INSURANCE: "reportSelection.leafInsurance",
+ GLUCOSE_PANEL: "reportSelection.leafGlucosePanel",
+ LAB_RESULTS: "reportSelection.leafLabResults",
+ MEDICATION_LIST: "reportSelection.leafMedicationList",
+ MEDICATION_ADMINISTRATIONS: "reportSelection.leafMedicationAdministrations",
+ MEDICATION_COMPLIANCE: "reportSelection.leafMedicationCompliance",
+ GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
+ ALLERGIES: "reportSelection.leafAllergies",
+ ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
+ FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
+ MOOD: "reportSelection.leafMood",
+ CYCLE: "reportSelection.leafCycle",
+};
+
+/** i18n label keys for the groups, including the fenced tier's heading. */
+export const REPORT_GROUP_LABEL_KEYS: Record = {
+ identity: "reportSelection.groupIdentity",
+ vitals: "reportSelection.groupVitals",
+ cardio: "reportSelection.groupCardio",
+ body: "reportSelection.groupBody",
+ sleepRecovery: "reportSelection.groupSleepRecovery",
+ activity: "reportSelection.groupActivity",
+ mobility: "reportSelection.groupMobility",
+ environment: "reportSelection.groupEnvironment",
+ glucose: "reportSelection.groupGlucose",
+ labs: "reportSelection.groupLabs",
+ medications: "reportSelection.groupMedications",
+ history: "reportSelection.groupHistory",
+ sensitive: "reportSelection.groupSensitive",
+};
+
+/**
+ * v1.18.0 — leaf → owning module. The module map still ANDs over the
+ * selection: a disabled module wins over a selected leaf, and a deselected
+ * leaf wins over an enabled module. Neither softens the other.
+ *
+ * Core clinical leaves (blood pressure, weight, pulse, body composition,
+ * identity) carry no module and are never gated here.
+ */
+export const LEAF_MODULE: Partial> = {
+ // Activity / movement series and the training-load scores.
+ ACTIVITY_STEPS: "workouts",
+ ACTIVE_ENERGY_BURNED: "workouts",
+ ENERGY_EXPENDITURE_KJ: "workouts",
+ FLIGHTS_CLIMBED: "workouts",
+ WALKING_RUNNING_DISTANCE: "workouts",
+ WALKING_SPEED: "workouts",
+ WALKING_ASYMMETRY: "workouts",
+ WALKING_STEP_LENGTH: "workouts",
+ WALKING_DOUBLE_SUPPORT: "workouts",
+ WALKING_STEADINESS: "workouts",
+ WALKING_STEADINESS_EVENT: "workouts",
+ SIX_MINUTE_WALK_DISTANCE: "workouts",
+ STAIR_ASCENT_SPEED: "workouts",
+ STAIR_DESCENT_SPEED: "workouts",
+ STRAIN_SCORE: "workouts",
+ DAY_STRAIN: "workouts",
+ WORKOUT_STRAIN: "workouts",
+ // Readiness signals.
+ RECOVERY_SCORE: "recovery",
+ STRESS_SCORE: "recovery",
+ RESILIENCE: "recovery",
+ ANS_CHARGE: "recovery",
+ // Sleep.
+ SLEEP_DURATION: "sleep",
+ SLEEP_SCORE: "sleep",
+ SLEEP_PERFORMANCE: "sleep",
+ SLEEP_EFFICIENCY: "sleep",
+ SLEEP_CONSISTENCY: "sleep",
+ SLEEP_NEED: "sleep",
+ SLEEP_DISTURBANCE_COUNT: "sleep",
+ BREATHING_DISTURBANCES: "sleep",
+ BREATHING_DISTURBANCE_EVENT: "sleep",
+ // Environment exposure.
+ AUDIO_EXPOSURE_ENV: "environment",
+ AUDIO_EXPOSURE_HEADPHONE: "environment",
+ AUDIO_EXPOSURE_EVENT: "environment",
+ TIME_IN_DAYLIGHT: "environment",
+ // Glucose: the raw series and the derived panel.
+ BLOOD_GLUCOSE: "glucose",
+ GLUCOSE_PANEL: "glucose",
+ // The screening totals ride the mental-health module as well as being
+ // fenced in the panel. Item-level answers are never a Measurement, so only
+ // the total — the intended clinical artefact — can reach this path at all.
+ PHQ9_SCORE: "mentalHealth",
+ GAD7_SCORE: "mentalHealth",
+ WHO5_SCORE: "mentalHealth",
+ SCI_SCORE: "mentalHealth",
+ // Structured sections with an owning module.
+ LAB_RESULTS: "labs",
+ MOOD: "mood",
+ CYCLE: "cycle",
+ ILLNESS_EPISODES: "illness",
+ MEDICATION_LIST: "medications",
+ MEDICATION_ADMINISTRATIONS: "medications",
+ MEDICATION_COMPLIANCE: "medications",
+ GLP1_THERAPY: "medications",
+};
+
+/** Every measurement leaf id, in enum order. */
+export const MEASUREMENT_LEAF_IDS = Object.keys(
+ MEASUREMENT_LEAF_GROUP,
+) as MeasurementType[];
+
+/** Every structured leaf id. */
+export const STRUCTURED_LEAF_IDS = Object.keys(
+ STRUCTURED_LEAF_GROUP,
+) as StructuredLeafId[];
+
+/**
+ * Every leaf id, grouped and ordered the way the panel renders them: group
+ * order first, structured leaves before measurement leaves inside a group.
+ * The PDF and the clinician view read the same order, so the panel is a table
+ * of contents for the artefact.
+ */
+export const REPORT_GROUPS: ReadonlyArray<{
+ id: ReportGroupId;
+ labelKey: string;
+ leaves: readonly ReportLeafId[];
+}> = REPORT_GROUP_ORDER.map((id) => ({
+ id,
+ labelKey: REPORT_GROUP_LABEL_KEYS[id],
+ leaves: [
+ ...STRUCTURED_LEAF_IDS.filter((leaf) => STRUCTURED_LEAF_GROUP[leaf] === id),
+ ...MEASUREMENT_LEAF_IDS.filter(
+ (leaf) => MEASUREMENT_LEAF_GROUP[leaf] === id,
+ ),
+ ],
+}));
+
+/** The full ordered leaf list — the stable order a stored selection uses. */
+export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap(
+ (group) => group.leaves,
+);
+
+const ALL_LEAF_SET = new Set(ALL_LEAF_IDS);
+
+/** Whether a raw string is a leaf id this build knows. */
+export function isReportLeafId(value: unknown): value is ReportLeafId {
+ return typeof value === "string" && ALL_LEAF_SET.has(value);
+}
+
+const STRUCTURED_LEAF_SET = new Set(STRUCTURED_LEAF_IDS);
+
+/** Whether a leaf id is one of the structured sections. */
+export function isStructuredLeafId(
+ leaf: ReportLeafId,
+): leaf is StructuredLeafId {
+ return STRUCTURED_LEAF_SET.has(leaf);
+}
+
+/** The fenced leaves, as a set, for the template-purity guard and the panel. */
+export const SENSITIVE_LEAF_IDS: readonly ReportLeafId[] = ALL_LEAF_IDS.filter(
+ (leaf) =>
+ isStructuredLeafId(leaf)
+ ? STRUCTURED_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID
+ : MEASUREMENT_LEAF_GROUP[leaf] === SENSITIVE_GROUP_ID,
+);
+
+/** The i18n label key for any leaf. */
+export function leafLabelKey(leaf: ReportLeafId): string {
+ return isStructuredLeafId(leaf)
+ ? STRUCTURED_LEAF_LABEL_KEYS[leaf]
+ : MEASUREMENT_TYPE_LABEL_KEYS[leaf];
+}
+
+/** The group a leaf belongs to. */
+export function leafGroup(leaf: ReportLeafId): ReportGroupId {
+ return isStructuredLeafId(leaf)
+ ? STRUCTURED_LEAF_GROUP[leaf]
+ : MEASUREMENT_LEAF_GROUP[leaf];
+}
diff --git a/src/lib/report-selection/saved-profile.ts b/src/lib/report-selection/saved-profile.ts
new file mode 100644
index 000000000..8dc7f6fd6
--- /dev/null
+++ b/src/lib/report-selection/saved-profile.ts
@@ -0,0 +1,72 @@
+/**
+ * The owner's saved report selection — "my selection".
+ *
+ * Persisted on `User.reportSelectionJson` and written after a successful
+ * generation, the same way the practice name is. Two kinds of caller read it:
+ *
+ * - the export panel and the share-link create form, so the panel opens
+ * already carrying the last scope the owner chose, and
+ * - the surfaces that cannot ask a human — the FHIR REST face and the MCP
+ * doctor-visit resource and prompt.
+ *
+ * The second group is the reason this exists. Those three call sites used to
+ * assemble the full record at server defaults. Now they replay a real act by
+ * the owner: the same object reviewed in Settings, no more and no less. An
+ * account that has never saved one resolves to the EMPTY selection, so an
+ * assistant asking for a record the owner never scoped gets nothing — not a
+ * template, not a guess.
+ */
+import { z } from "zod/v4";
+
+import { prisma } from "@/lib/db";
+import {
+ EMPTY_REPORT_SELECTION,
+ reportSelectionSchema,
+ selectionFromStoredBlob,
+ type ReportSelection,
+} from "./selection";
+
+/**
+ * The stored profile: the leaf list plus the render choices the panel
+ * restores. `format` / `rangeDays` / `includeCharts` are presentation, not
+ * scope — no gate reads them — so they ride alongside rather than inside the
+ * selection.
+ */
+export const savedReportProfileSchema = reportSelectionSchema.extend({
+ format: z.enum(["pdf", "fhir", "package"]),
+ rangeDays: z.number().int().min(1).max(365),
+ includeCharts: z.boolean(),
+});
+
+export type SavedReportProfile = z.infer;
+
+/** What the panel seeds from when the account has never saved a profile. */
+export const SAVED_PROFILE_FALLBACK = {
+ format: "pdf",
+ rangeDays: 90,
+ includeCharts: true,
+} as const;
+
+/**
+ * Resolve the owner's saved selection.
+ *
+ * Anything that is not a well-formed v2 blob — null, a drifted shape, a
+ * hand-edited row — resolves to the empty selection. A shape the server cannot
+ * read is not consent to anything.
+ */
+export async function resolveSavedSelection(
+ userId: string,
+): Promise {
+ const row = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { reportSelectionJson: true },
+ });
+ if (!row) return EMPTY_REPORT_SELECTION;
+ return selectionFromStoredBlob(row.reportSelectionJson);
+}
+
+/** Parse a stored profile blob for the panel, or null when there is none. */
+export function parseSavedProfile(raw: unknown): SavedReportProfile | null {
+ const parsed = savedReportProfileSchema.safeParse(raw);
+ return parsed.success ? parsed.data : null;
+}
diff --git a/src/lib/report-selection/selection.ts b/src/lib/report-selection/selection.ts
new file mode 100644
index 000000000..50adaaf96
--- /dev/null
+++ b/src/lib/report-selection/selection.ts
@@ -0,0 +1,131 @@
+/**
+ * The resolved report selection: an inclusion list of leaf ids, and the only
+ * three ways to mint one.
+ *
+ * Membership is inclusion. Absence is exclusion. There is no defaults constant
+ * anywhere in this module, because a default that means "yes" is the defect
+ * this shape exists to remove: the flat preferences object it replaces
+ * resolved an absent key to "included", so a caller who sent nothing received
+ * the whole record.
+ *
+ * The brand means a call site cannot hand `collectDoctorReportData` an object
+ * literal. It has to go through one of the three minting functions below, and
+ * those are the only place the version policy and the unknown-id policy live.
+ */
+import { z } from "zod/v4";
+
+import { isReportLeafId, type ReportLeafId, ALL_LEAF_IDS } from "./catalogue";
+
+declare const brand: unique symbol;
+
+/** The stored/wire version. Bumped only when the leaf grammar itself changes. */
+export const REPORT_SELECTION_VERSION = 2;
+
+export interface ReportSelection {
+ readonly [brand]: "report-selection";
+ readonly version: typeof REPORT_SELECTION_VERSION;
+ /** Whether this selection admits a leaf. The one question every gate asks. */
+ has(leaf: ReportLeafId): boolean;
+ /** The chosen leaves in catalogue order — for the audit row and scope line. */
+ readonly leaves: readonly ReportLeafId[];
+}
+
+/** The wire shape of a selection, as sent and as stored. */
+export const reportSelectionSchema = z
+ .object({
+ v: z.literal(REPORT_SELECTION_VERSION),
+ leaves: z.array(z.string().min(1).max(64)).max(ALL_LEAF_IDS.length),
+ })
+ .strict();
+
+export type ReportSelectionInput = z.infer;
+
+const LEAF_ORDER = new Map(
+ ALL_LEAF_IDS.map((leaf, index) => [leaf, index]),
+);
+
+function mint(leaves: Iterable): ReportSelection {
+ const set = new Set(leaves);
+ const ordered = ALL_LEAF_IDS.filter((leaf) => set.has(leaf));
+ return {
+ version: REPORT_SELECTION_VERSION,
+ has: (leaf: ReportLeafId) => set.has(leaf),
+ leaves: ordered,
+ } as unknown as ReportSelection;
+}
+
+/**
+ * Mint from an explicit leaf list — the shipped template, the panel's own
+ * state, and tests. Callers pass ids the compiler already checked.
+ */
+export function selectionFromLeaves(
+ leaves: Iterable,
+): ReportSelection {
+ return mint(leaves);
+}
+
+/** The empty selection: nothing chosen, so nothing is served. */
+export const EMPTY_REPORT_SELECTION: ReportSelection = mint([]);
+
+/** What went wrong parsing a request selection, for the 422 that names it. */
+export interface SelectionRequestError {
+ /** Leaf ids the caller sent that this build does not know. */
+ unknownLeaves: string[];
+}
+
+/**
+ * Mint from the Zod-parsed `selection` field of an API body.
+ *
+ * Unknown ids are REJECTED rather than dropped: a client sending an id the
+ * server does not know is a contract mismatch the client has to see, and
+ * dropping it would silently narrow a scope the caller did express.
+ */
+export function selectionFromRequest(
+ input: ReportSelectionInput,
+):
+ | { ok: true; selection: ReportSelection }
+ | { ok: false; error: SelectionRequestError } {
+ const unknownLeaves = input.leaves.filter((leaf) => !isReportLeafId(leaf));
+ if (unknownLeaves.length > 0) {
+ return { ok: false, error: { unknownLeaves } };
+ }
+ return { ok: true, selection: mint(input.leaves as ReportLeafId[]) };
+}
+
+/**
+ * Mint from a stored blob — a share link's frozen `sectionsJson` or the
+ * owner's saved profile.
+ *
+ * Unknown ids are DROPPED and never added to: a leaf removed in a later
+ * version simply stops being part of that selection. Anything that is not a
+ * well-formed v2 blob resolves to the EMPTY selection, not to a default set —
+ * a shape the server cannot read is not consent to anything. That is what
+ * makes the legacy share links in migration 0273 revocable rather than
+ * silently re-scoped.
+ */
+export function selectionFromStoredBlob(raw: unknown): ReportSelection {
+ const parsed = reportSelectionSchema.safeParse(raw);
+ if (!parsed.success) return EMPTY_REPORT_SELECTION;
+ return mint(parsed.data.leaves.filter(isReportLeafId));
+}
+
+/** Serialise a selection to the stored / wire shape. */
+export function selectionToBlob(
+ selection: ReportSelection,
+): ReportSelectionInput {
+ return { v: REPORT_SELECTION_VERSION, leaves: [...selection.leaves] };
+}
+
+/** Whether this selection admits nothing at all. */
+export function isEmptySelection(selection: ReportSelection): boolean {
+ return selection.leaves.length === 0;
+}
+
+/** Stable ordering helper, exported for the panel's own state folding. */
+export function orderLeaves(
+ leaves: Iterable,
+): readonly ReportLeafId[] {
+ return [...new Set(leaves)].sort(
+ (a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0),
+ );
+}
diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts
new file mode 100644
index 000000000..b83811681
--- /dev/null
+++ b/src/lib/report-selection/template.ts
@@ -0,0 +1,46 @@
+/**
+ * The one shipped template: what a general doctor's visit usually needs.
+ *
+ * It is applied on the FIRST run only, with the panel open, so the user
+ * presses Generate having seen twelve named groups and an empty fenced tier.
+ * That is consent to a named bundle, not a server-chosen default — nothing
+ * here is applied to a stored selection, to a share link, or to any surface
+ * that cannot ask a human.
+ *
+ * The template intersects the fenced tier at zero leaves, and
+ * `report-template-purity.test.ts` fails the build if that stops being true.
+ */
+import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
+
+/**
+ * Identity and insurer (the cover page a practice files the document under),
+ * the three classic vitals, weight and BMI, the glucose series and its panel,
+ * labs, the drug list and adherence, allergies and past illness episodes.
+ *
+ * Deliberately not in it: everything a wearable produces, gait and mobility,
+ * environment exposure, the per-dose administration ledger (a clinician asks
+ * for adherence, not for every logged dose), the GLP-1 block, and the whole
+ * fenced tier.
+ */
+export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
+ "PATIENT_IDENTITY",
+ "INSURANCE",
+ "BLOOD_PRESSURE_SYS",
+ "BLOOD_PRESSURE_DIA",
+ "PULSE",
+ "WEIGHT",
+ "BODY_MASS_INDEX",
+ "BLOOD_GLUCOSE",
+ "GLUCOSE_PANEL",
+ "LAB_RESULTS",
+ "MEDICATION_LIST",
+ "MEDICATION_COMPLIANCE",
+ "ALLERGIES",
+ "ILLNESS_EPISODES",
+];
+
+/** The template's i18n label — it is named in the panel, never anonymous. */
+export const STANDARD_TEMPLATE_LABEL_KEY = "reportSelection.templateStandard";
+
+/** Exported so the purity guard reads the same set the panel does. */
+export const SENSITIVE_LEAVES = SENSITIVE_LEAF_IDS;
diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts
index 6e0428acf..cbe22ff72 100644
--- a/src/lib/validations/clinician-share-link.ts
+++ b/src/lib/validations/clinician-share-link.ts
@@ -8,18 +8,28 @@
* widen/update path. `expiresAt` is REQUIRED and capped at
* `SHARE_LINK_MAX_DAYS` so no link ever lives forever.
*
- * The `resourceTypes` / `allowFhirApi` keys are gone. A share link
- * serves a rendered page and the documents frozen onto it; it has never served
- * FHIR, and accepting a scope for a face that does not exist only made the
- * promise look kept. An unknown key is rejected by `.strict()`, so a caller
- * still sending them gets a 422 that names them rather than a silent no-op.
+ * The `resourceTypes` / `allowFhirApi` request keys are gone. They configured
+ * a scoped FHIR face for a share link that was never built, and accepting a
+ * scope for a face that does not exist only made the promise look kept. An
+ * unknown key is rejected by `.strict()`, so a caller still sending them gets
+ * a 422 that names them rather than a silent no-op. The link's record IS
+ * reachable in machine form now — as a download at `/c/{token}/fhir`, behind
+ * the same unlock the page uses, serving exactly the frozen selection.
*
* Strict: `.strict()` rejects unknown keys; there is intentionally no `userId`
* field (the owner is always narrowed from `requireAuth()`).
*/
import { z } from "zod/v4";
-import { exportSectionsSchema } from "@/lib/validations/health-record-export";
+import { reportSelectionSchema } from "@/lib/report-selection/selection";
+
+/**
+ * The one leaf a share link may never carry. The share view has never
+ * decrypted the insurance number, so refusing it here makes that structural
+ * instead of incidental: the create route 422s naming the leaf, and no path
+ * exists by which a link could later be widened to include it.
+ */
+export const SHARE_LINK_FORBIDDEN_LEAVES = ["INSURANCE"] as const;
/** Maximum lifetime of a share link, in days. No never-expiring share. */
export const SHARE_LINK_MAX_DAYS = 90;
@@ -48,7 +58,7 @@ export const createShareLinkSchema = z
label: z.string().trim().min(1).max(120),
rangeStart: z.iso.datetime({ offset: true }),
rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(),
- sections: exportSectionsSchema.optional(),
+ selection: reportSelectionSchema.optional(),
documentIds: z
.array(z.string().trim().min(1).max(64))
.max(SHARE_LINK_MAX_DOCUMENTS)
@@ -58,7 +68,7 @@ export const createShareLinkSchema = z
* report scope and mints a link that serves ONLY the attached documents —
* never a health metric. "Share this document" means the document, not the
* whole record. A documents-only share MUST carry at least one
- * `documentId` (enforced below). Any `sections` sent alongside are ignored
+ * `documentId` (enforced below). Any `selection` sent alongside is ignored
* server-side so a document link can never widen into a record.
*/
documentOnly: z.boolean().optional(),
diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts
deleted file mode 100644
index 48d8c5859..000000000
--- a/src/lib/validations/doctor-report-prefs.ts
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * v1.4.25 W6c — per-user Doctor-Report section toggles.
- *
- * Persisted as a Json blob on `User.doctorReportPrefsJson`. Null = the
- * documented defaults (every section ON except mood). The dialog reads
- * the row, lets the user flip checkboxes, and writes the chosen shape
- * back. The PDF generator + the report aggregator both consult the
- * shape: aggregator drops mood data entirely when `mood = false` so the
- * data never leaves the DB row (privacy-by-default), and the generator
- * skips each section whose flag is false at render time.
- *
- * The shape is intentionally additive — every new data type added to the
- * report grows the schema with a new optional flag and a new entry in
- * `DEFAULT_DOCTOR_REPORT_PREFS`. Forward-compat: an unknown / drifted
- * shape falls back to defaults rather than throwing.
- */
-import { z } from "zod/v4";
-
-/**
- * Section toggle schema. Every key is optional so a partial update from
- * the dialog (e.g., "the user only flipped mood") doesn't have to
- * re-state every other flag. The route layer fills missing keys from the
- * defaults before persisting so the column shape stays stable.
- */
-export const doctorReportPrefsSchema = z
- .object({
- bp: z.boolean(),
- weight: z.boolean(),
- pulse: z.boolean(),
- bmi: z.boolean(),
- mood: z.boolean(),
- medicationList: z.boolean(),
- compliance: z.boolean(),
- sleep: z.boolean(),
- glucose: z.boolean(),
- cycle: z.boolean(),
- labs: z.boolean(),
- allergies: z.boolean(),
- familyHistory: z.boolean(),
- oxygenSaturation: z.boolean(),
- bodyFat: z.boolean(),
- bodyComposition: z.boolean(),
- restingHeartRate: z.boolean(),
- hrv: z.boolean(),
- vo2max: z.boolean(),
- steps: z.boolean(),
- distance: z.boolean(),
- mentalHealthScreeners: z.boolean(),
- })
- .partial();
-
-export type DoctorReportPrefsInput = z.infer;
-
-/**
- * Fully-resolved section toggles. Every key required so the consumers
- * (PDF renderer + aggregator) don't have to thread an "is this key
- * present?" check through their render paths.
- */
-export interface DoctorReportPrefs {
- bp: boolean;
- weight: boolean;
- pulse: boolean;
- bmi: boolean;
- mood: boolean;
- /**
- * The active-medication list (names, doses, schedules) and the
- * per-dose administration ledger. Previously exported unconditionally while
- * the only medication control folded into {@link DoctorReportPrefs.compliance}.
- */
- medicationList: boolean;
- compliance: boolean;
- sleep: boolean;
- glucose: boolean;
- cycle: boolean;
- labs: boolean;
- allergies: boolean;
- familyHistory: boolean;
- /**
- * The measurement groups the export panel has always rendered a
- * control for but nothing gated. Each one now drives
- * `MEASUREMENT_TYPE_SECTION` in the aggregator, so it reaches PDF, FHIR and
- * the package alike.
- */
- oxygenSaturation: boolean;
- bodyFat: boolean;
- bodyComposition: boolean;
- restingHeartRate: boolean;
- hrv: boolean;
- vo2max: boolean;
- steps: boolean;
- distance: boolean;
- /**
- * PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. Depression,
- * anxiety, wellbeing and insomnia instruments: opt-in per artefact like
- * mood, never carried by an absent key.
- */
- mentalHealthScreeners: boolean;
-}
-
-/**
- * Defaults applied when the user has never opened the dialog. Every
- * section is ON by default EXCEPT mood, which is opt-in per the maintainer's
- * privacy directive (2026-05-14): mental-health data should never appear
- * in a clinical PDF the user didn't explicitly check.
- */
-export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
- bp: true,
- weight: true,
- pulse: true,
- bmi: true,
- mood: false, // privacy default per the maintainer
- // The medication list has always ridden along; the flag exists so the panel's
- // single "medications" control can actually withhold it, not to change what a
- // caller that omits the key receives.
- medicationList: true,
- compliance: true,
- sleep: true,
- // Glucose the user recorded to share with a clinician — ON by default,
- // like BP / weight / labs. The per-report toggle lets a diabetic user
- // withhold glucose from a SPECIFIC shared report without disabling the
- // glucose module app-wide (default-on preserves the pre-toggle behaviour,
- // where glucose always rendered).
- glucose: true,
- // Cycle data is opt-in: a user sharing a BP report with a cardiologist
- // should not auto-leak reproductive data. Same privacy stance as mood.
- cycle: false,
- // Lab results the user recorded for exactly this purpose — sharing
- // bloodwork with a clinician. ON by default, like BP / weight.
- labs: true,
- // Structured allergy / intolerance records — the section every clinical
- // intake asks for first. Reference data recorded to share, ON by default
- // like labs.
- allergies: true,
- // Structured family history — same stance as allergies.
- familyHistory: true,
- // The measurement groups that gained a real gate here. They default ON
- // because that is what an omitted key has always produced; the point of the
- // flags is that a caller who switches one OFF is now obeyed. The web panel
- // sends every one of them explicitly.
- oxygenSaturation: true,
- bodyFat: true,
- bodyComposition: true,
- restingHeartRate: true,
- hrv: true,
- vo2max: true,
- steps: true,
- distance: true,
- // Mental-health screening totals are opt-in per artefact, same stance as
- // mood: PHQ-9 and GAD-7 are depression and anxiety instruments and must not
- // leave the instance because a caller omitted a key. The `mentalHealth`
- // module gate still applies on top — it just no longer stands alone, having
- // been default-ON since v1.29.1.
- mentalHealthScreeners: false,
-};
-
-/**
- * The "no section" shape — every toggle OFF. A share link that carries ONLY
- * documents (the "share this document, not the whole record" flow) persists
- * this so the clinician view resolves to an empty report scope and NO health
- * data is ever aggregated for it. Distinct from the null / `{}` case, which
- * resolves to {@link DEFAULT_DOCTOR_REPORT_PREFS} (a full record share).
- */
-export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
- bp: false,
- weight: false,
- pulse: false,
- bmi: false,
- mood: false,
- medicationList: false,
- compliance: false,
- sleep: false,
- glucose: false,
- cycle: false,
- labs: false,
- allergies: false,
- familyHistory: false,
- oxygenSaturation: false,
- bodyFat: false,
- bodyComposition: false,
- restingHeartRate: false,
- hrv: false,
- vo2max: false,
- steps: false,
- distance: false,
- mentalHealthScreeners: false,
-};
-
-/**
- * Whether any report section is enabled. A resolved prefs object with every
- * flag OFF means "no health report" — the load-bearing signal a documents-only
- * share reads to serve zero metrics. The clinician-view data loader gates the
- * whole doctor-report aggregation on this: false ⇒ never touch the DB for
- * health data.
- */
-export function hasAnyReportSection(prefs: DoctorReportPrefs): boolean {
- return Object.values(prefs).some(Boolean);
-}
-
-/**
- * Parse a row's `doctorReportPrefsJson` Json blob into a typed
- * `DoctorReportPrefs`, falling back to the documented defaults when the
- * row is null OR the persisted shape has drifted (a forward-compat field
- * rename, an admin-side hand-edit, etc.). Missing keys are filled from
- * the defaults so callers always get a fully-resolved object.
- */
-export function parseDoctorReportPrefs(raw: unknown): DoctorReportPrefs {
- if (raw == null) return { ...DEFAULT_DOCTOR_REPORT_PREFS };
- const parsed = doctorReportPrefsSchema.safeParse(raw);
- if (!parsed.success) return { ...DEFAULT_DOCTOR_REPORT_PREFS };
- return {
- ...DEFAULT_DOCTOR_REPORT_PREFS,
- ...parsed.data,
- };
-}
-
-/**
- * Resolve a partial input (typically from a PUT body) into the full
- * canonical shape, layering the supplied keys over the current
- * persisted row (or defaults when null). Keeps the route layer free of
- * merge plumbing.
- */
-export function resolveDoctorReportPrefs(
- current: unknown,
- incoming: DoctorReportPrefsInput,
-): DoctorReportPrefs {
- const base = parseDoctorReportPrefs(current);
- return { ...base, ...incoming };
-}
diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts
index 310cd26c2..3b0ffea17 100644
--- a/src/lib/validations/health-record-export.ts
+++ b/src/lib/validations/health-record-export.ts
@@ -1,155 +1,28 @@
/**
- * v1.7.0 — health-record / doctor-handover export selection.
+ * The health-record / doctor-handover export selection.
*
- * Single strict Zod schema driving `POST /api/export/health-record`. The
- * flagship export route fails loudly on bad input (unlike the legacy
- * doctor-report route which tolerates drift) — `.strict()` rejects unknown
- * keys, and there is intentionally NO `userId` field (the user is always
- * narrowed from `requireAuth()`, never accepted from the body).
+ * One strict Zod schema driving `POST /api/export/health-record`. `.strict()`
+ * rejects unknown keys, and there is intentionally NO `userId` field — the
+ * user is always narrowed from `requireAuth()`, never accepted from the body.
*
- * The grouped `sections` shape is additive over the flat
- * `DoctorReportPrefs` used by the aggregator + PDF renderer. The
- * `toDoctorReportPrefs()` helper folds the grouped toggles down to the
- * flat shape the aggregator consumes, so PDF and FHIR describe identical
- * numbers (the source-of-truth property the two PDF endpoints share).
+ * `selection` is REQUIRED and is the leaf inclusion list. The grouped
+ * `sections` shape it replaces resolved an absent key to "included", so a
+ * caller who sent nothing received roughly fifty metrics plus the medication
+ * list, labs, allergies and family history — a scope nobody chose. Sending
+ * `sections` now 422s with the key named rather than being folded into a
+ * default, because silently accepting the old shape would mean silently
+ * choosing a scope the caller did not express.
+ *
+ * The AI summary is gone with it. Its text was pulled from the cached
+ * briefing with no relation to the selection at all, so its scope could not be
+ * proven — and prose whose scope cannot be proven cannot ride a document whose
+ * whole point is a proven scope.
*/
import { z } from "zod/v4";
+
import { locales } from "@/lib/i18n/config";
-import {
- DEFAULT_DOCTOR_REPORT_PREFS,
- parseDoctorReportPrefs,
- type DoctorReportPrefs,
-} from "@/lib/validations/doctor-report-prefs";
+import { reportSelectionSchema } from "@/lib/report-selection/selection";
-/** Grouped vital toggles. */
-const vitalsGroupSchema = z
- .object({
- weight: z.boolean(),
- bp: z.boolean(),
- pulse: z.boolean(),
- oxygenSaturation: z.boolean(),
- bodyFat: z.boolean(),
- bodyComposition: z.boolean(),
- })
- .partial();
-
-const cardioFitnessGroupSchema = z
- .object({
- restingHeartRate: z.boolean(),
- hrv: z.boolean(),
- vo2max: z.boolean(),
- })
- .partial();
-
-const activityGroupSchema = z
- .object({
- steps: z.boolean(),
- distance: z.boolean(),
- sleep: z.boolean(),
- })
- .partial();
-
-/**
- * `list` gates the medication list plus the per-dose administration
- * ledger; `compliance` gates the adherence figures and the GLP-1 therapy block.
- * The retired `glp1` / `sideEffects` / `activity.energy` keys were accepted and
- * discarded: no surface ever rendered them and no code read them. An unknown
- * key inside a group is stripped, so a caller still sending one is unaffected.
- */
-const medicationsGroupSchema = z
- .object({
- list: z.boolean(),
- compliance: z.boolean(),
- })
- .partial();
-
-/**
- * Grouped section toggles. Every group + key is optional so a partial
- * payload (the user flipped one checkbox) doesn't have to re-state the
- * whole tree. Missing keys resolve to the documented defaults.
- */
-export const exportSectionsSchema = z
- .object({
- vitals: vitalsGroupSchema,
- cardioFitness: cardioFitnessGroupSchema,
- activity: activityGroupSchema,
- glucose: z.boolean(),
- medications: medicationsGroupSchema,
- mood: z.boolean(),
- bmi: z.boolean(),
- // Cycle / reproductive health — opt-in (privacy default OFF, like mood).
- cycle: z.boolean(),
- // Structured lab results — ON by default (recorded to share with a
- // clinician, same stance as BP / weight).
- labs: z.boolean(),
- // Structured allergy / intolerance records — ON by default (the
- // section every clinical intake asks for first; same stance as labs).
- allergies: z.boolean(),
- // Structured family history — ON by default, same stance as allergies.
- familyHistory: z.boolean(),
- // PHQ-9 / GAD-7 / WHO-5 / SCI screening totals. OFF unless the
- // caller sets it explicitly (`=== true`), like mood and cycle.
- mentalHealthScreeners: z.boolean(),
- })
- .partial();
-
-export type ExportSections = z.infer;
-
-/**
- * The top-level keys that exist ONLY in the grouped {@link exportSectionsSchema}
- * (never in the flat `doctorReportPrefsSchema`). Their presence is the reliable
- * signal that a persisted `sectionsJson` blob is the grouped shape rather than
- * the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top
- * level, the grouped shape nests them under `vitals`.
- *
- * `glucose` is deliberately NOT a discriminator: it is a top-level boolean in
- * BOTH shapes (the flat `DoctorReportPrefs` gained its own `glucose` section
- * toggle), so its presence no longer distinguishes grouped from flat. A real
- * grouped export always carries one of the nested groups below, so detection
- * stays reliable without it — and a bare `{ glucose: false }` blob correctly
- * resolves through the flat parser.
- */
-const GROUPED_SECTION_KEYS = [
- "vitals",
- "cardioFitness",
- "activity",
- "medications",
-] as const;
-
-/**
- * v1.28.17 — resolve a STORED `sectionsJson` blob (from a clinician share link
- * or any consumer that persists the export selection) to the flat
- * {@link DoctorReportPrefs} the aggregator + PDF renderer consume.
- *
- * The share-link create schema accepts the GROUPED {@link exportSectionsSchema}
- * and persists it raw, but the report aggregator speaks the FLAT shape. Reading
- * a grouped blob through the flat parser silently DROPS every grouped toggle
- * (`vitals.bp`, `activity.sleep`, `medications.compliance`) and falls back to
- * defaults-ON — so a section the owner switched OFF would still be served. This
- * resolver closes that gap: a grouped blob is folded through
- * {@link toDoctorReportPrefs}; a flat / empty / `{}` / null blob keeps the exact
- * legacy semantics via {@link parseDoctorReportPrefs}. Detection is by the
- * grouped-only keys, so it can never misread a flat blob as grouped (or vice
- * versa), and it needs no data migration — existing rows resolve correctly.
- */
-export function resolveStoredReportSections(raw: unknown): DoctorReportPrefs {
- if (raw != null && typeof raw === "object" && !Array.isArray(raw)) {
- const obj = raw as Record;
- const looksGrouped = GROUPED_SECTION_KEYS.some((k) => k in obj);
- if (looksGrouped) {
- const parsed = exportSectionsSchema.safeParse(obj);
- // A well-formed grouped blob folds down; a drifted one falls through to
- // the flat parser, which itself defaults on any shape it cannot read.
- if (parsed.success) return toDoctorReportPrefs(parsed.data);
- }
- }
- return parseDoctorReportPrefs(raw);
-}
-
-/**
- * Flagship export selection. `.strict()` so unknown keys (including any
- * attempt to smuggle a `userId`) 422 via `returnAllZodIssues`.
- */
export const exportSelectionSchema = z
.object({
format: z.enum(["pdf", "fhir", "package"]),
@@ -161,11 +34,11 @@ export const exportSelectionSchema = z
})
.strict()
.optional(),
- sections: exportSectionsSchema.optional(),
+ /** The leaf inclusion list. Required: a scope has to be stated. */
+ selection: reportSelectionSchema,
locale: z.enum(locales).optional(),
practiceName: z.string().max(120).optional(),
includeCharts: z.boolean().optional(),
- includeAiSummary: z.boolean().optional(),
// FHIR only: additionally emit the German BfArM ATC URI alongside the WHO
// entry on each medication concept (WHO stays first, byte-identical). When
// omitted, the route derives it from a German-region locale.
@@ -174,60 +47,3 @@ export const exportSelectionSchema = z
.strict();
export type ExportSelection = z.infer;
-
-/**
- * Fold the grouped selection down to the flat `DoctorReportPrefs` the
- * aggregator + PDF renderer consume. A group toggle that is `true` (or
- * absent, falling back to the section default) flips the matching flat
- * flag. Mood, cycle and the mental-health screeners stay privacy-default-OFF:
- * they only flip on when the caller set them explicitly.
- *
- * The fold used to read twelve of the twenty keys the schema
- * accepts and drop the rest on the floor, so a caller that switched off SpO₂,
- * body fat, body composition, resting heart rate, HRV, VO₂max, steps or
- * distance was silently overruled and the export panel rendered eight controls
- * that changed nothing. Every accepted key now resolves to a flat flag, and
- * `doctor-report-control-gating-guard.test.ts` fails the build if a control is
- * ever added back without one.
- */
-export function toDoctorReportPrefs(
- sections: ExportSections | undefined,
-): DoctorReportPrefs {
- const s = sections ?? {};
- const v = s.vitals ?? {};
- const c = s.cardioFitness ?? {};
- const a = s.activity ?? {};
- const m = s.medications ?? {};
-
- const fallback = DEFAULT_DOCTOR_REPORT_PREFS;
- return {
- weight: v.weight ?? fallback.weight,
- bp: v.bp ?? fallback.bp,
- pulse: v.pulse ?? fallback.pulse,
- bmi: s.bmi ?? fallback.bmi,
- // Mood is opt-in: only true when explicitly requested.
- mood: s.mood === true,
- medicationList: m.list ?? fallback.medicationList,
- // A caller that sends only `medications.list = false` means "no medication
- // data" — the single panel control has always driven both keys together, so
- // the list value keeps standing in for a missing compliance value.
- compliance: m.compliance ?? m.list ?? fallback.compliance,
- sleep: a.sleep ?? fallback.sleep,
- glucose: s.glucose ?? fallback.glucose,
- // Cycle is opt-in: only true when explicitly requested (privacy).
- cycle: s.cycle === true,
- labs: s.labs ?? fallback.labs,
- allergies: s.allergies ?? fallback.allergies,
- familyHistory: s.familyHistory ?? fallback.familyHistory,
- oxygenSaturation: v.oxygenSaturation ?? fallback.oxygenSaturation,
- bodyFat: v.bodyFat ?? fallback.bodyFat,
- bodyComposition: v.bodyComposition ?? fallback.bodyComposition,
- restingHeartRate: c.restingHeartRate ?? fallback.restingHeartRate,
- hrv: c.hrv ?? fallback.hrv,
- vo2max: c.vo2max ?? fallback.vo2max,
- steps: a.steps ?? fallback.steps,
- distance: a.distance ?? fallback.distance,
- // Screening totals are opt-in: only true when explicitly requested.
- mentalHealthScreeners: s.mentalHealthScreeners === true,
- };
-}