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} - ) : ( -