From b6db529deefdc244cb7ddd232b1f8bc39e02a84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 02:00:48 +0200 Subject: [PATCH] feat(doctor-report): wire the doctorReport module toggle to the health-record surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctorReport module toggle was inert: turning it off hid nothing and the server-side export route accepted requests regardless. Gate the live surface on the module — the Health-record settings entry now hides client-side when the module is off, and POST /api/export/health-record refuses with a 403 module.disabled envelope, so the doctor-report surface cannot be reached over a Bearer token either. --- .../export/health-record/__tests__/route.test.ts | 15 +++++++++++++++ src/app/api/export/health-record/route.ts | 8 ++++++++ .../settings/__tests__/settings-shell.test.tsx | 13 +++++++++++++ .../settings/gesundheitsakte-section.tsx | 6 +++++- src/components/settings/settings-shell.tsx | 6 +++++- 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 837913e89..661a4fa47 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -132,6 +132,21 @@ describe("POST /api/export/health-record — validation", () => { expect(res.status).toBe(422); }); + it("returns 403 when the doctorReport module is disabled (B3 gate)", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError('Module "doctorReport" is not enabled', 403, { + errorCode: "module.disabled", + module: "doctorReport", + }), + } as never); + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir" })); + expect(res.status).toBe(403); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "doctorReport"); + }); + it("returns 429 when the export rate limit is exhausted", async () => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false, diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 073af830a..a236ec3ca 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -31,6 +31,7 @@ import { safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; import { @@ -56,6 +57,13 @@ 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. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); if (!rl.allowed) { return apiError("Maximum 10 exports per hour", 429); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index eca579f0a..a7fbe3a8f 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -235,6 +235,19 @@ describe("", () => { expect(disabled).not.toContain('href="/settings/coach"'); }); + it("module-gates the Health-record entry off `user.modules.doctorReport` (B3)", () => { + // Fail-open default: doctorReport undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/gesundheitsakte"', + ); + // Explicitly disabled → entry hidden. + const disabled = renderShell({ + active: "account", + modules: { doctorReport: false }, + }); + expect(disabled).not.toContain('href="/settings/gesundheitsakte"'); + }); + it("falls back to `account` when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index 880b5b6aa..4a3a9bb3f 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -10,7 +10,11 @@ * the generic CSV / JSON data-out paths. Export & Import now keeps only * the generic data export/import surfaces. * - * Not module-gated: the health record is always available. + * Module-gated on `doctorReport` (v1.18.0 B3): the nav entry hides when the + * module is off (see `SETTINGS_SECTIONS` in `settings-shell.tsx`), and the + * server-side `/api/export/health-record` route refuses with a 403 + * `module.disabled` envelope — so a disabled doctor-report surface cannot be + * reached over a Bearer token either. */ import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel"; diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index d96b4160a..9e773d6eb 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -157,11 +157,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) - // earns its own home, lifted out of Export & Import. Always available. + // earns its own home, lifted out of Export & Import. Gated on the + // `doctorReport` module: when the user turns the doctor-report surface off, + // the entry hides (the server-side `/api/export/health-record` gate is the + // hard enforcement; this hides the entry-point to match). { slug: "gesundheitsakte", titleKey: "settings.sections.gesundheitsakte.title", icon: FileHeart, + moduleGate: "doctorReport", }, { slug: "export",