From 5e490f31ec0e30534d526a75a03ef1e83ad59e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 17:51:33 +0200 Subject: [PATCH] feat(export): make the doctor report carry only what was chosen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report resolved an absent key to "included". A caller that sent no sections object, or an empty group inside one, received the full record at server-chosen defaults: roughly fifty metrics plus the medication list, labs, allergies and family history. The web panel sent every key explicitly so it looked fine from the browser, but the share link, the FHIR REST face, the MCP doctor-visit resource and the cycle-only export card all went through paths where absence meant yes. No arrangement of booleans fixes a default that means yes, so the shape goes. A selection is now an inclusion list of leaf ids. A leaf is the smallest thing that can be released or withheld: every measurement type, and thirteen structured sections — identity, insurer, the glucose panel, labs, the drug list, the administration ledger, adherence, the GLP-1 block, allergies, illness episodes, family history, mood, cycle. Membership is inclusion, absence is exclusion, and there is no defaults constant anywhere to drift. Three locks keep a metric added later off a report chosen earlier. A stored selection is a list of what was chosen, so a leaf that did not exist then is not in it. The catalogue is keyed over the measurement enum, so adding a member without deciding its group fails typecheck in the same commit. And the aggregator's filter inverts: a type the catalogue does not know is excluded and annotated, where it used to be passed through. The aggregator's third parameter is now required and brand-typed, which turned the three surfaces that had no scope at all into compile errors. Each one had to name a source, and each now replays the owner's saved selection — the same object reviewed in Settings. An account that never saved one gets nothing there rather than a guess. Identity and insurance become selectable, and a share link refuses the insurance leaf outright. The share view never decrypted the number; that is structural now instead of incidental. The KVNR is not even read unless the leaf was chosen. Two surfaces gain a record they always implied. A share link serves its frozen selection as a FHIR bundle and as the PDF, behind the same unlock cookie the document route uses, with its own rate bucket, its own audit row and the same blunt 404 for every miss. That is what the removed FHIR toggle promised, in the form a practice actually uses. The AI summary leaves the report. Its text came from the cached briefing with no relation to the selection, so its scope could not be proven, and prose whose scope cannot be proven has no place on a document whose whole point is a proven scope. The PDF table stops being a nine-type whitelist. It prints every selected leaf that has data, sub-headed by group, in the same order the panel shows — so selecting resting heart rate no longer means seeing it in the bundle and not in the PDF. The clinician view is grouped the same way and reads real labels instead of "Phq9 score". Migration 0273 adds the saved-selection column, drops the orphaned prefs column and the two retired share-link scope columns, and revokes every live record share link minted before this model. Their frozen scope was "everything except mood and cycle", which is a scope nobody chose; narrowing it would still be a scope nobody chose. Documents-only shares are untouched. The sharing list shows each closed link in a pick-again state with its label, window and expiry kept. The retired allowFhirApi and resourceTypes stay on the create and list responses as the constant false and empty list. The request refuses them. They come off the response once the native client confirms it does not decode them. Six files are split at seams this change already touched: the aggregator into its gate, its dense-bucket query, its series, its glucose panel, its medication ledger and its clinical records; the export route's three emitters into their own module; the clinician view into its sections, its document list and its downloads. --- .../0273_report_selection/migration.sql | 29 + prisma/schema.prisma | 43 +- .../__tests__/route.test.ts | 210 --- .../api/auth/me/doctor-report-prefs/route.ts | 109 -- src/app/api/auth/me/report-selection/route.ts | 109 ++ src/app/api/export/health-record/route.ts | 288 ++-- src/app/api/meta/capabilities/route.ts | 26 +- src/app/api/share-links/route.ts | 92 +- src/app/c/[token]/fhir/route.ts | 46 + src/app/c/[token]/page.tsx | 38 +- src/app/c/[token]/report.pdf/route.ts | 51 + src/components/clinician/clinician-view.tsx | 333 +--- src/components/clinician/documents-list.tsx | 98 ++ src/components/clinician/download-actions.tsx | 46 + src/components/clinician/report-sections.tsx | 232 +++ src/lib/clinician-share/report-download.ts | 132 ++ src/lib/clinician-share/request-locale.ts | 26 + src/lib/clinician-share/share-view-data.ts | 31 +- src/lib/doctor-report-data.ts | 1430 +---------------- src/lib/doctor-report-pdf-core.ts | 94 +- .../clinical-records-notes-section.ts | 59 +- src/lib/doctor-report-pdf/clinical-summary.ts | 5 +- .../measurements-charts-section.ts | 93 +- src/lib/doctor-report-pdf/render-context.ts | 12 +- src/lib/doctor-report-selection.ts | 177 -- src/lib/doctor-report-types.ts | 26 +- src/lib/doctor-report/clinical-records.ts | 157 ++ src/lib/doctor-report/collect.ts | 577 +++++++ src/lib/doctor-report/dense-buckets.ts | 211 +++ src/lib/doctor-report/glucose-panel.ts | 146 ++ src/lib/doctor-report/measurement-series.ts | 161 ++ src/lib/doctor-report/medications.ts | 259 +++ src/lib/doctor-report/selection-gate.ts | 103 ++ src/lib/doctor-report/type-label-keys.ts | 24 - src/lib/export/health-record-artefacts.ts | 76 + src/lib/mcp/prompts.ts | 17 +- src/lib/mcp/resources.ts | 13 +- src/lib/measurements/type-label-keys.ts | 110 ++ src/lib/openapi/routes/meta.ts | 21 +- src/lib/report-selection/catalogue.ts | 380 +++++ src/lib/report-selection/saved-profile.ts | 72 + src/lib/report-selection/selection.ts | 131 ++ src/lib/report-selection/template.ts | 46 + src/lib/validations/clinician-share-link.ts | 26 +- src/lib/validations/doctor-report-prefs.ts | 228 --- src/lib/validations/health-record-export.ts | 224 +-- 46 files changed, 3734 insertions(+), 3083 deletions(-) create mode 100644 prisma/migrations/0273_report_selection/migration.sql delete mode 100644 src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/doctor-report-prefs/route.ts create mode 100644 src/app/api/auth/me/report-selection/route.ts create mode 100644 src/app/c/[token]/fhir/route.ts create mode 100644 src/app/c/[token]/report.pdf/route.ts create mode 100644 src/components/clinician/documents-list.tsx create mode 100644 src/components/clinician/download-actions.tsx create mode 100644 src/components/clinician/report-sections.tsx create mode 100644 src/lib/clinician-share/report-download.ts create mode 100644 src/lib/clinician-share/request-locale.ts delete mode 100644 src/lib/doctor-report-selection.ts create mode 100644 src/lib/doctor-report/clinical-records.ts create mode 100644 src/lib/doctor-report/collect.ts create mode 100644 src/lib/doctor-report/dense-buckets.ts create mode 100644 src/lib/doctor-report/glucose-panel.ts create mode 100644 src/lib/doctor-report/measurement-series.ts create mode 100644 src/lib/doctor-report/medications.ts create mode 100644 src/lib/doctor-report/selection-gate.ts delete mode 100644 src/lib/doctor-report/type-label-keys.ts create mode 100644 src/lib/export/health-record-artefacts.ts create mode 100644 src/lib/measurements/type-label-keys.ts create mode 100644 src/lib/report-selection/catalogue.ts create mode 100644 src/lib/report-selection/saved-profile.ts create mode 100644 src/lib/report-selection/selection.ts create mode 100644 src/lib/report-selection/template.ts delete mode 100644 src/lib/validations/doctor-report-prefs.ts 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 ( -
-

{title}

- {children} -
- ); -} - -/** - * 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 - {title} - ) : ( -