feat(doctor-report): wire the doctorReport module toggle to the health-record surface

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:00:48 +02:00
1 parent 69fe67e2de
commit b6db529dee
5 files changed
+46 -2

No files matched your search

@@ -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,
@@ -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);
@@ -235,6 +235,19 @@ describe("<SettingsShell>", () => {
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 =
@@ -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";
+5 -1
View File
@@ -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",