diff --git a/docs/doctor-report.md b/docs/doctor-report.md deleted file mode 100644 index c3743191e..000000000 --- a/docs/doctor-report.md +++ /dev/null @@ -1,76 +0,0 @@ -# Doctor Report - -The doctor report is a printable PDF summary of a user's tracked vitals, -medication compliance, glucose context, and mood over a configurable window -(default 90 days, max 365). It is intended as discussion material for a -doctor's appointment, **not** a medical diagnosis. - -## Two endpoints - -The same aggregation logic backs both endpoints — `collectDoctorReportData` -in `src/lib/doctor-report-data.ts` is the single source of truth — so the -JSON payload and the server-rendered PDF are guaranteed to be in sync. - -### `POST /api/doctor-report` — JSON payload - -Returns the aggregated `DoctorReportData` envelope. Used by the PWA's -client-side renderer (`src/lib/doctor-report-pdf.ts`, jsPDF + autotable). -Best on desktop browsers and on Android, where local generation keeps the -PDF off the wire and works offline (cached page). - -- Auth: session required. -- Rate-limit: 10/h per user. -- Body: `{ days?: number }` (default 90). -- Response: `200 application/json` with envelope `{ data, error: null }`. -- Audit action: `doctor-report.generate`. - -### `POST /api/doctor-report/pdf` — server-rendered PDF - -Returns the ready-to-download PDF bytes. Used primarily on iOS/Safari, where -the client-side jsPDF download UX is unreliable (Safari sometimes opens the -PDF in a new tab without preserving the filename or fails the blob save). - -- Auth: session required (`401` otherwise). -- Rate-limit: 10/h per user, shared with the JSON endpoint (`429` on excess). -- Body: `{ days?: number; locale?: "de" | "en" }`. Body is optional — empty - bodies are accepted. -- Locale resolution: explicit `body.locale` wins, then the - `Accept-Language` header (`de*` → `de`, anything else → `en`), then the - hard fallback `de`. -- Response: `200 application/pdf` with - `Content-Disposition: attachment; filename="healthlog-report-YYYY-MM-DD.pdf"` - and `Cache-Control: no-store`. Body is the raw PDF bytes. -- Errors: JSON envelope `{ data: null, error: "..." }` for `401` and `429`; - `500` if rendering fails. -- Audit action: `doctor-report.pdf.generate`. - -## Wellness summary (derived scores) - -Since v1.10.0 the report carries a **Wellness summary** section below the -clinical content, holding the server-derived wellness scores (Recovery, -Stress, Strain) and the other derived metrics where present. It is kept -deliberately separate from the clinical vitals table: - -- Each entry is labelled **descriptive, not a clinical assessment**, and the - scores never enter the vitals table or the clinical aggregations. -- The Stress score is shown as an HRV-derived proxy, not a sensor-measured - stress value. -- In the FHIR export the same scores are emitted as `survey`-category - `Observation`s (text-only `code`, no fabricated LOINC) with a per-Observation - non-clinical note — so a downstream system never mistakes them for a vital - sign or a diagnosis. - -Each derived metric states its inputs, method, and the cited standard it is -grounded in (the same provenance the in-app explainer shows). - -## Implementation notes - -- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer. - jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid - `%PDF-` byte stream in both environments, so we deliberately avoid headless - Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low. -- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the - raw `jsPDF` instance so the existing settings-page download flow (which - calls `doc.save(...)`) keeps working unchanged. -- Server-side translations come from `messages/{de,en}.json` via - `getServerTranslator()` (`src/lib/i18n/server-translator.ts`). diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index d312df72f..cede31394 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -76,7 +76,11 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "src/app/api/labs", "src/app/api/cycle", "src/app/api/gamification", - "src/app/api/doctor-report", + // v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF + + // availability probe) was orphaned dead code (no production caller) and + // removed. The live doctor-report / FHIR surface is `/api/export/health-record`, + // which gates on the `doctorReport` module directly AND through the + // `collectDoctorReportData` builder. "src/app/api/fhir", ]; @@ -118,10 +122,6 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/labs/route.ts", "src/app/api/labs/[id]/route.ts", // ── INFRA / UI-ONLY ─────────────────────────────────────────────── - // Section-availability presence probe for the doctor-report dialog — - // returns per-section row-count booleans, no module data. The dialog - // itself is hidden when the doctorReport module is off. - "src/app/api/doctor-report/availability/route.ts", // Static FHIR CapabilityStatement — server metadata, no user data. "src/app/api/fhir/metadata/route.ts", ]; diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts deleted file mode 100644 index e95d87afc..000000000 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// --- Mocks must come before importing the route. --- - -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { findMany: vi.fn() }, - medication: { findMany: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, - moodEntry: { findMany: vi.fn() }, - labResult: { findMany: vi.fn() }, - user: { findUnique: vi.fn(), update: vi.fn() }, - auditLog: { create: vi.fn() }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ - getSession: vi.fn(), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn(), -})); - -vi.mock("@/lib/geo", () => ({ - lookupIpLocation: vi.fn(), -})); - -vi.mock("@/lib/analytics/effective-range", () => ({ - getEffectiveRange: vi.fn(() => ({ range: null })), -})); - -vi.mock("@/lib/logging/transports", () => ({ - emitIfSampled: vi.fn(), -})); - -// v1.18.0 — the doctor-report aggregator resolves the per-user module map -// so a disabled data-domain module never reaches the report. Stub the gate -// to "all modules enabled" (an empty map ⇒ default-on) so these pre-existing -// route tests don't stand up the real gate's DB reads. -vi.mock("@/lib/modules/gate", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveModuleMap: vi.fn(), - isModuleEnabled: vi.fn(), - requireModuleEnabled: vi.fn(), - }; -}); - -import { POST } from "../pdf/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { auditLog } from "@/lib/auth/audit"; -import { - resolveModuleMap, - isModuleEnabled, - requireModuleEnabled, -} from "@/lib/modules/gate"; - -function setModuleGateAllEnabled() { - vi.mocked(resolveModuleMap).mockResolvedValue({} as never); - vi.mocked(isModuleEnabled).mockResolvedValue(true); - vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); -} - -function makeRequest( - body?: Record, - headers: Record = {}, -): NextRequest { - const baseHeaders: Record = { - "content-type": "application/json", - ...headers, - }; - return new NextRequest("http://localhost/api/doctor-report/pdf", { - method: "POST", - headers: baseHeaders, - body: body === undefined ? undefined : JSON.stringify(body), - }); -} - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, - user: { id: "user-1", role: "USER" as const }, -}; - -function setEmptyDataMocks() { - vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( - [] as never, - ); - vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - username: "testuser", - dateOfBirth: null, - gender: null, - heightCm: null, - glucoseUnit: null, - thresholdsJson: null, - } as never); -} - -describe("POST /api/doctor-report/pdf", () => { - beforeEach(() => { - vi.resetAllMocks(); - setModuleGateAllEnabled(); - }); - - it("returns 401 when not authenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await POST(makeRequest({ days: 90 })); - expect(res.status).toBe(401); - const json = (await res.json()) as { error: string }; - expect(json.error).toBe("Not authenticated"); - }); - - it("returns 429 when rate limit exceeded", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 3600_000, - }); - - const res = await POST(makeRequest({ days: 90 })); - expect(res.status).toBe(429); - const json = (await res.json()) as { error: string }; - expect(json.error).toMatch(/Maximum/); - }); - - it("returns 200 application/pdf with PDF bytes on happy path", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST( - makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }), - ); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/pdf"); - expect(res.headers.get("cache-control")).toBe("no-store"); - const disposition = res.headers.get("content-disposition") ?? ""; - expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/); - expect(disposition).toMatch(/\.pdf"/); - - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.byteLength).toBeGreaterThan(1024); - expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-"); - - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ days: 30, locale: "de" }), - }), - ); - }); - - it("falls back to default days=90 when body omits it", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST(makeRequest({})); - expect(res.status).toBe(200); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ days: 90 }), - }), - ); - }); - - it("uses Accept-Language to pick the locale when body omits it", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST( - makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }), - ); - expect(res.status).toBe(200); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ locale: "en" }), - }), - ); - }); - - // ── v1.4.15 phase B6 ── configurable date range + practice name. - - it("accepts an explicit startDate / endDate range in the body", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const startDate = "2026-01-01T00:00:00.000Z"; - const endDate = "2026-04-01T00:00:00.000Z"; - const res = await POST(makeRequest({ startDate, endDate })); - expect(res.status).toBe(200); - expect(prisma.measurement.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - measuredAt: { gte: new Date(startDate), lte: new Date(endDate) }, - }), - }), - ); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ startDate, endDate }), - }), - ); - }); - - it("falls back when range is invalid (end < start)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST( - makeRequest({ - startDate: "2026-04-01T00:00:00.000Z", - endDate: "2026-01-01T00:00:00.000Z", - }), - ); - expect(res.status).toBe(200); - // Falls back to days=90. - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ days: 90 }), - }), - ); - }); - - it("persists practiceName as a user preference and includes it in audit", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await POST( - makeRequest({ days: 30, practiceName: "Praxis Dr. Müller" }), - ); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { lastReportPracticeName: "Praxis Dr. Müller" }, - }); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ practiceNameProvided: true }), - }), - ); - }); -}); diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts deleted file mode 100644 index 209f04966..000000000 --- a/src/app/api/doctor-report/availability/route.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * v1.4.25 W6c — doctor-report section availability probe. - * - * Returns one boolean per toggleable section so the dialog can hide - * checkboxes for data types that have zero rows in the user-selected - * date range (the maintainer's "hide-when-empty" rule). Pure presence check — - * `count` queries on small indexed tables — so this is cheap enough to - * run on every date-range change in the dialog without driving cost. - * - * Body shape (mirrors `/api/doctor-report`): - * { startDate?: ISO, endDate?: ISO, days?: number } - */ -import type { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { apiSuccess, safeJson } from "@/lib/api-response"; -import { prisma } from "@/lib/db"; -import { normaliseDateRange } from "@/lib/doctor-report-data"; - -export const dynamic = "force-dynamic"; - -export interface DoctorReportAvailability { - /** Section flags — `true` when at least one row exists in the range. */ - bp: boolean; - weight: boolean; - pulse: boolean; - /** BMI requires both a weight reading AND a configured height. */ - bmi: boolean; - mood: boolean; - /** Medication compliance — derived from `MedicationIntakeEvent` rows. */ - compliance: boolean; - sleep: boolean; -} - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "doctor-report.availability" } }); - - const { data: body, error } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (error) return error; - const range = normaliseDateRange(body ?? undefined); - const { start, end } = range; - - // `count` queries are cheaper than `findMany` for a presence check — - // we never read the rows themselves. The user has indexes on - // `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and - // `(userId, scheduledFor)` so these stay sub-millisecond even with - // multi-year ranges. - const [ - bpCount, - weightCount, - pulseCount, - moodCount, - complianceCount, - sleepCount, - profile, - ] = await Promise.all([ - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, - // v1.4.41 W-DELETED-2 — hide soft-deleted rows from the - // section-availability probe so the dialog does not offer a - // section that would render an empty PDF block. - deletedAt: null, - }, - }), - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: "WEIGHT", - deletedAt: null, - }, - }), - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: "PULSE", - deletedAt: null, - }, - }), - prisma.moodEntry.count({ - // v1.7.0 sync — exclude tombstoned rows from the availability count. - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: start, lte: end }, - }, - }), - prisma.medicationIntakeEvent.count({ - // v1.7.0 sync — exclude tombstoned rows from the availability count. - where: { - userId: user.id, - deletedAt: null, - scheduledFor: { gte: start, lte: end }, - }, - }), - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: "SLEEP_DURATION", - deletedAt: null, - }, - }), - prisma.user.findUnique({ - where: { id: user.id }, - select: { heightCm: true }, - }), - ]); - - // BMI is derived (latest weight × profile height) so it only shows up - // when BOTH inputs are available — otherwise the user would tick a - // box that produces a silently-empty section. - const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null; - - const availability: DoctorReportAvailability = { - bp: bpCount > 0, - weight: weightCount > 0, - pulse: pulseCount > 0, - bmi, - mood: moodCount > 0, - compliance: complianceCount > 0, - sleep: sleepCount > 0, - }; - - annotate({ - meta: { - report_days: range.days, - sections_available: Object.values(availability).filter(Boolean).length, - }, - }); - - return apiSuccess(availability); -}); diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts deleted file mode 100644 index d64f12467..000000000 --- a/src/app/api/doctor-report/pdf/route.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { apiError, getClientIp } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { - collectDoctorReportData, - normaliseDateRange, - sanitisePracticeName, -} from "@/lib/doctor-report-data"; -import { prisma } from "@/lib/db"; -import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; -import { - DEFAULT_DOCTOR_REPORT_PREFS, - doctorReportPrefsSchema, - type DoctorReportPrefs, -} from "@/lib/validations/doctor-report-prefs"; -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"; - -/** - * Server-rendered PDF doctor report. - * - * Mirrors `/api/doctor-report` (which returns JSON for client-side rendering) - * but produces the finished PDF bytes server-side. Useful on iOS / Safari where - * the client-side jsPDF download UX is unreliable. - * - * Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint. - */ -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "doctor-report.pdf.generate" } }); - - const rl = await checkRateLimit( - `doctor-report:${user.id}`, - 10, - 60 * 60 * 1000, - ); - if (!rl.allowed) { - return apiError("Maximum 10 reports per hour", 429); - } - - // Body is optional — JSON is allowed but not required. - const body = await readOptionalJsonBody(request); - const range = normaliseDateRange(body ?? undefined); - const locale = resolveLocale( - body?.locale, - request.headers.get("accept-language"), - ); - const rawPracticeName = body?.practiceName; - const practiceName = sanitisePracticeName(rawPracticeName); - - // v1.4.25 W6c — section toggles. Malformed shapes silently fall back - // to documented defaults so a forward-compat client never lands a - // 422 on a benign drift. - const sectionsParsed = doctorReportPrefsSchema.safeParse( - body?.sections ?? {}, - ); - const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {}; - const sections: DoctorReportPrefs = { - ...DEFAULT_DOCTOR_REPORT_PREFS, - ...sectionsInput, - }; - - if (typeof rawPracticeName === "string" && practiceName !== null) { - try { - await prisma.user.update({ - where: { id: user.id }, - data: { lastReportPracticeName: practiceName }, - }); - } catch { - // Best-effort; never let preference persistence break the report. - } - } - - const [data, userTz] = await Promise.all([ - collectDoctorReportData(user.id, range, { practiceName, sections }), - resolveUserTimezone(user.id), - ]); - - const { t } = getServerTranslator(locale); - const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale, userTz }); - - await auditLog("doctor-report.pdf.generate", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - days: range.days, - startDate: range.start.toISOString(), - endDate: range.end.toISOString(), - locale, - practiceNameProvided: practiceName !== null, - // v1.4.25 W6c — record exactly which sections this report - // rendered, so a future privacy review can confirm mood was - // never rendered when the user opted out. - sections, - }, - }); - - annotate({ - meta: { - report_days: range.days, - report_start: range.start.toISOString(), - report_end: range.end.toISOString(), - locale, - bytes: pdfBytes.byteLength, - practice_name_provided: practiceName !== null, - }, - }); - - const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`; - // NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid - // SharedArrayBuffer typing surprises with Uint8Array. - const buffer = pdfBytes.slice().buffer; - return new NextResponse(buffer, { - status: 200, - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="${filename}"`, - "Content-Length": String(pdfBytes.byteLength), - "Cache-Control": "no-store", - }, - }); -}); - -interface PdfRequestBody { - days?: unknown; - startDate?: unknown; - endDate?: unknown; - locale?: unknown; - practiceName?: unknown; - /** v1.4.25 W6c — per-section visibility toggles. */ - sections?: unknown; -} - -/** - * Best-effort JSON body parse. Returns `null` if the request had no body or - * a non-JSON content type — both are acceptable for this endpoint. - */ -async function readOptionalJsonBody( - request: NextRequest, -): Promise { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.includes("application/json")) return null; - try { - const raw = await request.text(); - // Small options object — treat an oversized body as no body. - if (raw.length > 64 * 1024) return null; - return JSON.parse(raw) as PdfRequestBody; - } catch { - return null; - } -} - -/** - * Resolve the active locale: explicit body override wins, then a parsed - * `Accept-Language`, then a hard fallback to German (the primary HealthLog - * locale for the medical context). - */ -function resolveLocale( - bodyLocale: unknown, - acceptLanguage: string | null, -): Locale { - if ( - typeof bodyLocale === "string" && - (locales as readonly string[]).includes(bodyLocale) - ) { - return bodyLocale as Locale; - } - if (!acceptLanguage) return "de"; - // parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise - // "en" — matching the broader app's i18n behaviour for present headers. - return parseLocaleFromAcceptLanguage(acceptLanguage); -} diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts deleted file mode 100644 index 776009f70..000000000 --- a/src/app/api/doctor-report/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - apiError, - getClientIp, - safeJson, -} from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { NextRequest } from "next/server"; -import { - collectDoctorReportData, - normaliseDateRange, - sanitisePracticeName, -} from "@/lib/doctor-report-data"; -import { prisma } from "@/lib/db"; -import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs"; - -/** - * Collect data for doctor report PDF generation (client-side). - * Returns aggregated health data for the specified time range. - * - * Body shape (all fields optional — sensible defaults apply): - * { - * startDate?: string, // ISO timestamp, inclusive - * endDate?: string, // ISO timestamp, inclusive - * days?: number, // legacy "last N days" fallback (1..365) - * practiceName?: string // free-text, persisted as user preference - * } - * - * Resolution: explicit range wins; otherwise `days` (default 90) is applied. - */ -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "doctor-report.generate" } }); - - const rl = await checkRateLimit( - `doctor-report:${user.id}`, - 10, - 60 * 60 * 1000, - ); - if (!rl.allowed) { - return apiError("Maximum 10 reports per hour", 429); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const range = normaliseDateRange(body); - const rawPracticeName = (body as Record | null) - ?.practiceName; - const practiceName = sanitisePracticeName(rawPracticeName); - - // v1.4.25 W6c — section toggles. Malformed shapes silently fall - // through to the documented defaults so a forward-compat client - // never lands a 422 on a benign drift. - const rawSections = (body as Record | null)?.sections; - const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {}); - const sections = sectionsParsed.success ? sectionsParsed.data : {}; - - // Persist the most-recent practice name as a user preference so the - // dialog can pre-fill it next time. We only write when the caller - // actually supplied a non-empty string — passing `null`/empty does NOT - // clear the stored preference (use the dedicated profile endpoint for - // that). Best-effort: a write failure here MUST NOT break the report. - if (typeof rawPracticeName === "string" && practiceName !== null) { - try { - await prisma.user.update({ - where: { id: user.id }, - data: { lastReportPracticeName: practiceName }, - }); - } catch { - // Non-fatal — preference persistence is a UX nicety, not a contract. - } - } - - const data = await collectDoctorReportData(user.id, range, { - practiceName, - sections, - }); - - await auditLog("doctor-report.generate", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - days: range.days, - startDate: range.start.toISOString(), - endDate: range.end.toISOString(), - practiceNameProvided: practiceName !== null, - // v1.4.25 W6c — record which sections were included. The audit - // trail proves mood data was never aggregated when the toggle - // was off (privacy contract). - sections, - }, - }); - - annotate({ - meta: { - report_days: range.days, - report_start: range.start.toISOString(), - report_end: range.end.toISOString(), - practice_name_provided: practiceName !== null, - }, - }); - - return apiSuccess(data); -}); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 71c6842dc..243248566 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -4,7 +4,6 @@ * - /api/export (legacy CSV/JSON endpoint), * - /api/export/full-backup (single-file JSON bundle), * - /api/export/measurements (per-type CSV), - * - /api/doctor-report/availability (section probe), * - /api/gamification/achievements (achievement progress aggregator). * * Every assertion is shape-level: the route's measurement read MUST scope @@ -156,30 +155,4 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { }); } }); - - it("/api/doctor-report/availability scopes every measurement count to deletedAt: null", async () => { - const { POST } = await import( - "../../doctor-report/availability/route" - ); - const req = new NextRequest( - "http://localhost/api/doctor-report/availability", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }, - ); - await POST(req); - const calls = vi.mocked(prisma.measurement.count).mock.calls; - // The probe runs four parallel measurement.count queries (BP / - // weight / pulse / sleep). All must filter tombstoned rows so a - // soft-deleted history does not light up a section the user has - // since wiped. - expect(calls.length).toBeGreaterThanOrEqual(4); - for (const [arg] of calls) { - expect(arg).toMatchObject({ - where: expect.objectContaining({ deletedAt: null }), - }); - } - }); }); 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/doctor-report/__tests__/doctor-report-dialog.test.tsx b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx deleted file mode 100644 index 1f6ffac63..000000000 --- a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -/** - * SSR contract tests for the doctor-report export dialog. - * - * Radix UI's `Dialog` portals its content out of the React tree, so - * `renderToStaticMarkup()` returns an empty string for the open dialog. - * Instead we assert the *contract* surfaces the dialog depends on: - * - * - All required i18n keys exist in EN + DE. - * - The component module compiles + imports cleanly. - * - The exported submit-payload type stays the same shape the API - * route reads (`{ startDate, endDate }`). - * - * Behavioural coverage (open → validate → submit → download) lives in - * `e2e/doctor-report.spec.ts`, which drives the dialog through Playwright. - * - * v1.4.15 phase B6. - */ - -const ROOT = join(__dirname, "../../../.."); - -function loadMessages(locale: "en" | "de"): Record { - return JSON.parse( - readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), - ); -} - -const REQUIRED_KEYS = [ - "title", - "description", - "startLabel", - "endLabel", - "preset90d", - "preset180d", - "preset365d", - "submit", - "cancel", - "errorEndBeforeStart", - "errorRangeTooLong", - "errorInvalidDate", -]; - -describe("doctor-report dialog — i18n contract", () => { - for (const locale of ["en", "de"] as const) { - it(`exposes every doctor-report dialog key in ${locale}.json`, () => { - const messages = loadMessages(locale); - const root = messages.doctorReport as Record | undefined; - expect( - root, - `doctorReport namespace missing in ${locale}.json`, - ).toBeDefined(); - const dialog = root!.dialog as Record | undefined; - expect( - dialog, - `doctorReport.dialog missing in ${locale}.json`, - ).toBeDefined(); - for (const key of REQUIRED_KEYS) { - expect( - dialog![key], - `doctorReport.dialog.${key} missing in ${locale}.json`, - ).toBeTruthy(); - expect(typeof dialog![key]).toBe("string"); - } - }); - - // v1.4.25 W6c — per-section toggle strings. - it(`exposes every doctor-report section-toggle key in ${locale}.json`, () => { - const messages = loadMessages(locale); - const root = messages.doctorReport as Record | undefined; - const sections = root!.sections as Record | undefined; - expect( - sections, - `doctorReport.sections missing in ${locale}.json`, - ).toBeDefined(); - const sectionKeys = [ - "title", - "bp", - "weight", - "pulse", - "bmi", - "mood", - "moodSensitive", - "compliance", - "sleep", - "empty", - ]; - for (const key of sectionKeys) { - expect( - sections![key], - `doctorReport.sections.${key} missing in ${locale}.json`, - ).toBeTruthy(); - expect(typeof sections![key]).toBe("string"); - } - }); - } - - it("EN dialog uses the expected English title + 90d preset", () => { - const en = loadMessages("en"); - const dialog = (en.doctorReport as { dialog: Record }) - .dialog; - expect(dialog.title).toContain("Doctor report"); - expect(dialog.preset90d.toLowerCase()).toContain("90"); - }); - - it("DE dialog uses the German title", () => { - const de = loadMessages("de"); - const dialog = (de.doctorReport as { dialog: Record }) - .dialog; - expect(dialog.title).toContain("Arztbericht"); - expect(dialog.preset90d).toContain("90"); - }); -}); - -describe("doctor-report dialog — module surface", () => { - it("exports `DoctorReportDialog` and a `DoctorReportSubmitPayload` type", async () => { - const mod = await import("../doctor-report-dialog"); - expect(typeof mod.DoctorReportDialog).toBe("function"); - // The type is erased at runtime; verifying export presence is the - // best we can do without `tsc --noEmit` here. - expect("DoctorReportDialog" in mod).toBe(true); - }); -}); diff --git a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx deleted file mode 100644 index 27c4d2a58..000000000 --- a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * v1.4.43 QoL (M4) — `` strike-through disabled rows. - * - * Pre-fix, sections without data in the current range were filtered - * out of the rendered list, so a user had no signal that "Compliance" - * was a candidate. The fix renders every section in `SECTION_ORDER`; - * empties show with a strike-through label + tooltip explaining why - * they're disabled. Submitting still force-clears their toggle so the - * server never renders an empty section. - * - * Lives in its own SSR file because the `` shell - * portals through Radix and is invisible to `renderToStaticMarkup`; - * here we exercise the toggle list directly through the internal - * `SectionToggles` export. - */ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { - __test_SECTION_ORDER, - SectionToggles, -} from "../doctor-report-dialog"; -import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe(" v1.4.43 M4 disabled-row behaviour", () => { - it("renders a disabled row + strike-through for sections without data", () => { - const onToggle = vi.fn(); - const html = render( - , - ); - - // Every section row mounts — bp + weight available, others disabled. - for (const key of __test_SECTION_ORDER) { - expect(html).toContain(`data-testid="doctor-report-section-${key}"`); - } - - // Pulse / compliance / sleep / mood / bmi rows carry the disabled - // marker so a future styling tweak can't silently re-enable them. - expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Resting pulse/, - ); - expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Medication adherence/, - ); - expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/); - - // v1.4.43 W10 design-M4 — disabled rows render with muted text - // colour (not strike-through, which reads as "deleted" rather - // than "unavailable"). The italic hint line below carries the - // semantic visually. - expect(html).toMatch(/text-muted-foreground[\s\S]*?Sleep duration/); - }); - - it("renders the unavailable-hint tooltip text via the title attribute", () => { - const html = render( - , - ); - // The disabled rows expose a `title=` attribute with the - // unavailable-hint copy so hovering a disabled toggle on desktop - // surfaces the explanation. - expect(html).toContain('title="No data in this period"'); - }); - - it("renders disabled rows with the de locale's hint copy", () => { - const html = render( - , - "de", - ); - // Every-section-empty triggers the wider empty-state hint instead - // of the per-row tooltip. Pin both copies are present in the de - // locale shipping. - expect(html).toContain("Für den gewählten Zeitraum"); - }); - - it("renders disabled rows when at least one section has data", () => { - const html = render( - , - "de", - ); - // German tooltip copy on the disabled rows. - expect(html).toContain('title="Keine Daten in diesem Zeitraum"'); - }); - - it("force-clears the displayed checked state for unavailable rows", () => { - // Even if `prefs.compliance === true` (e.g. previously persisted), - // the displayed switch reads `false` when the section is not - // available in the range. The user can't toggle the disabled - // switch so the desync resolves itself; pin it here. - const html = render( - , - ); - // The compliance row exists and is marked unavailable… - expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Medication adherence/, - ); - // …and the disabled switch is rendered (aria-disabled or - // data-disabled survives in the SSR markup). - expect(html).toContain('data-testid="doctor-report-section-compliance"'); - }); -}); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx deleted file mode 100644 index 4af0bd050..000000000 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ /dev/null @@ -1,687 +0,0 @@ -"use client"; - -/** - * Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c). - * - * Wraps the trigger button so the user picks: - * 1. Reporting period (date range, with three presets). - * 2. Practice / clinic name (optional, persisted between exports). - * 3. Which data sections appear in the PDF (per-user persisted toggles). - * - * Hide-when-empty (the maintainer 2026-05-14): a section's toggle is only shown - * when the selected date range actually has data for it — checking a box - * for an empty section would produce a silently-empty PDF page. The - * availability probe runs on every range change via - * `/api/doctor-report/availability`. - * - * Privacy default: mood is OFF by default per the maintainer — mental-health data - * is opt-in even within a single user's own surface. The API layer - * filters mood out of the report payload server-side when the toggle is - * off, so the data never leaves the DB row. - * - * Defaults: - * - end = today - * - start = today − 90 days - * - sections = persisted prefs OR documented defaults - * - * Validation runs in the client so the dialog can show inline errors - * without bouncing through the server. The server still re-validates via - * `normaliseDateRange()` (silent fallback to last-90-days) — the client - * messages are UX, not security. - */ - -import { useEffect, useMemo, useRef, useState } from "react"; -import { Loader2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { DateInput } from "@/components/ui/date-input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - DEFAULT_DOCTOR_REPORT_PREFS, - type DoctorReportPrefs, -} from "@/lib/validations/doctor-report-prefs"; -import { useTranslations } from "@/lib/i18n/context"; -import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; - -const ONE_DAY_MS = 86_400_000; -const MAX_RANGE_DAYS = 730; -const PRACTICE_NAME_MAX_LENGTH = 120; - -/** - * Order in which section toggles render. Matches the order the sections - * appear in the generated PDF so the dialog's mental model lines up with - * the printed artefact. `mood` is intentionally last because its - * privacy-sensitive footnote needs prime visual placement. - */ -const SECTION_ORDER: ReadonlyArray = [ - "bp", - "weight", - "pulse", - "bmi", - "compliance", - "sleep", - "mood", -] as const; - -interface SectionAvailability { - bp: boolean; - weight: boolean; - pulse: boolean; - bmi: boolean; - mood: boolean; - compliance: boolean; - sleep: boolean; - // v1.15.0 — cycle section. Not rendered by this legacy dialog - // (SECTION_ORDER omits it); the flagship health-record export surfaces - // the cycle toggle. Present so `keyof DoctorReportPrefs` indexing - // typechecks. Optional — the availability endpoint may not provide it. - cycle?: boolean; - // v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog; - // the flagship export panel surfaces the toggle. Optional — the - // availability endpoint may not provide it. Present so the - // `keyof DoctorReportPrefs` indexing typechecks. - labs?: boolean; -} - -export interface DoctorReportSubmitPayload { - startDate: string; - endDate: string; - practiceName: string | null; - /** - * Per-section toggles. The caller forwards this to - * `/api/doctor-report` so the aggregator drops disabled sections - * (mood specifically is filtered at the data layer for privacy). - */ - sections: DoctorReportPrefs; -} - -interface DoctorReportDialogProps { - /** Controlled open state. */ - open: boolean; - onOpenChange: (open: boolean) => void; - /** Last-used practice name from `/api/auth/me`; pre-fills the input. */ - defaultPracticeName?: string | null; - /** - * Resolves with the user-confirmed payload. The dialog stays open and - * shows the spinner while the promise is pending; closes on resolve. - */ - onSubmit: (payload: DoctorReportSubmitPayload) => Promise; -} - -/** YYYY-MM-DD in the user's local timezone. */ -function todayIso(): string { - const now = new Date(); - return formatLocalDate(now); -} - -function formatLocalDate(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - -function defaultStartIso(): string { - const d = new Date(); - d.setDate(d.getDate() - 90); - return formatLocalDate(d); -} - -function rangeIsoFromLocal( - startDate: string, - endDate: string, -): { - startIso: string; - endIso: string; -} { - // Send dates as ISO timestamps anchored to the local day's start/end - // so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` captures everything - // logged on each boundary day. - return { - startIso: new Date(`${startDate}T00:00:00`).toISOString(), - endIso: new Date(`${endDate}T23:59:59.999`).toISOString(), - }; -} - -export function DoctorReportDialog({ - open, - onOpenChange, - defaultPracticeName, - onSubmit, -}: DoctorReportDialogProps) { - const { t } = useTranslations(); - const [startDate, setStartDate] = useState(defaultStartIso); - const [endDate, setEndDate] = useState(todayIso); - const [practiceName, setPracticeName] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - // Per-user section preferences. Loaded lazily when the dialog opens — - // skips a roundtrip on every render of the export card. - const [prefs, setPrefs] = useState( - DEFAULT_DOCTOR_REPORT_PREFS, - ); - const [prefsLoaded, setPrefsLoaded] = useState(false); - - // Section availability for the currently-selected range. `null` = - // "we haven't probed yet" so the section group renders a skeleton row - // rather than flashing an empty state. - const [availability, setAvailability] = useState( - null, - ); - const [availabilityLoading, setAvailabilityLoading] = useState(false); - const availabilityRequestId = useRef(0); - - // Reset on open: re-anchor dates + practice-name to defaults each time - // the dialog appears so a follow-up export doesn't reuse the previous - // custom range. Using the React-recommended "track-the-trigger" pattern - // (store the last observed `open` state and react during render) so the - // reset happens in the render phase rather than a setState-in-effect — - // satisfies the strict `react-hooks/set-state-in-effect` rule. - const [lastOpen, setLastOpen] = useState(false); - if (open && !lastOpen) { - setLastOpen(true); - setStartDate(defaultStartIso()); - setEndDate(todayIso()); - setPracticeName(defaultPracticeName ?? ""); - setError(null); - setSubmitting(false); - } else if (!open && lastOpen) { - setLastOpen(false); - } - - // Load persisted section prefs when the dialog opens for the first - // time. Best-effort: a failure falls back to documented defaults so - // the dialog stays usable even if the preferences endpoint is down. - useEffect(() => { - if (!open || prefsLoaded) return; - let cancelled = false; - apiGet | null | undefined>( - "/api/auth/me/doctor-report-prefs", - { credentials: "include" }, - ) - .then((incoming) => { - if (cancelled) return; - if (incoming && typeof incoming === "object") { - setPrefs({ ...DEFAULT_DOCTOR_REPORT_PREFS, ...incoming }); - } - setPrefsLoaded(true); - }) - .catch(() => { - if (!cancelled) setPrefsLoaded(true); - }); - return () => { - cancelled = true; - }; - }, [open, prefsLoaded]); - - // Re-probe availability whenever the range changes. setState calls - // live inside the promise handlers (microtask-deferred), satisfying - // the strict `react-hooks/set-state-in-effect` rule while keeping the - // loading indicator promptly visible. A generation counter prevents - // a slow earlier response from stomping on a faster later response - // (classic stale-write race). - useEffect(() => { - if (!open) return; - if (!startDate || !endDate) return; - const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate); - const requestId = ++availabilityRequestId.current; - let cancelled = false; - - Promise.resolve() - .then(() => { - if (cancelled) return null; - setAvailabilityLoading(true); - return apiPost( - "/api/doctor-report/availability", - { startDate: startIso, endDate: endIso }, - { credentials: "include" }, - ); - }) - .then((data) => { - if (cancelled || !data) return; - if (requestId !== availabilityRequestId.current) return; - setAvailability(data); - }) - .catch(() => { - // Best-effort — keep the previous availability snapshot. - }) - .finally(() => { - if (!cancelled && requestId === availabilityRequestId.current) { - setAvailabilityLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [open, startDate, endDate]); - - const validation = useMemo(() => { - if (!startDate || !endDate) { - return { - ok: false as const, - key: "doctorReport.dialog.errorInvalidDate", - }; - } - const start = new Date(startDate); - const end = new Date(endDate); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return { - ok: false as const, - key: "doctorReport.dialog.errorInvalidDate", - }; - } - if (end.getTime() < start.getTime()) { - return { - ok: false as const, - key: "doctorReport.dialog.errorEndBeforeStart", - }; - } - const spanDays = Math.ceil((end.getTime() - start.getTime()) / ONE_DAY_MS); - if (spanDays > MAX_RANGE_DAYS) { - return { - ok: false as const, - key: "doctorReport.dialog.errorRangeTooLong", - }; - } - return { ok: true as const }; - }, [startDate, endDate]); - - function applyPreset(days: number) { - const end = new Date(); - const start = new Date(); - start.setDate(start.getDate() - days); - setStartDate(formatLocalDate(start)); - setEndDate(formatLocalDate(end)); - setError(null); - } - - // v1.4.43 QoL (M4) — pre-fix this memo built the on-screen toggle - // list, so sections without data in the current range vanished - // entirely. The dialog now renders the full `SECTION_ORDER` with - // strike-through disabled rows for the empties, and the submission - // payload still force-clears any unavailable toggle so the server - // never renders an empty section. - - function toggleSection(key: keyof DoctorReportPrefs, value: boolean) { - setPrefs((current) => ({ ...current, [key]: value })); - } - - async function persistPrefs(next: DoctorReportPrefs) { - // Best-effort PUT — a network blip mustn't block PDF generation. The - // current dialog still submits with the chosen `next` value either - // way, so the user gets the PDF they asked for even if persistence - // failed (they'll just see the defaults next time). - try { - await apiPut("/api/auth/me/doctor-report-prefs", next, { - credentials: "include", - }); - } catch { - // ignore - } - } - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - if (!validation.ok) { - setError(t(validation.key)); - return; - } - setSubmitting(true); - setError(null); - try { - const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate); - const trimmedPractice = practiceName.trim(); - - // Compose the section payload: keep the persisted prefs for keys - // that ARE available in the range; force `false` for keys that - // aren't (so the server never tries to render an empty section). - const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS }; - for (const key of SECTION_ORDER) { - sections[key] = (availability?.[key] ?? false) && prefs[key] === true; - } - - // Fire-and-forget persistence so the user doesn't wait on it. - void persistPrefs(prefs); - - await onSubmit({ - startDate: startIso, - endDate: endIso, - practiceName: - trimmedPractice.length > 0 - ? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH) - : null, - sections, - }); - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); - } finally { - setSubmitting(false); - } - } - - return ( - - - - {t("doctorReport.dialog.title")} - - {t("doctorReport.dialog.description")} - - - -
-
-
- - { - setStartDate(e.target.value); - setError(null); - }} - required - /> -
-
- - { - setEndDate(e.target.value); - setError(null); - }} - required - /> -
-
- -
- - - -
- -
- - setPracticeName(e.target.value)} - autoComplete="organization" - /> -
- - {/* ── Section toggles ──────────────────────────────────────── */} - {/* - v1.4.43 QoL (M4) — pass the full `SECTION_ORDER` (not the - availability-filtered `activeSections`) so the user sees a - row for every section that *could* be in the report. Rows - without data in the current range render disabled with a - strike-through label + tooltip, so the user understands - which section is empty and why their PDF has fewer - sections than expected. The submission payload still - force-clears any unavailable toggle, so the server never - renders an empty section. - */} - - - {error && ( -

- {error} -

- )} - - - - - - -
-
- ); -} - -// ────────────────────────────── Subviews ────────────────────────────── - -/** - * Exported alongside `SectionToggles` for the SSR component test. The - * order array is the canonical render order both inside the live - * dialog and inside the test. - */ -export const __test_SECTION_ORDER = [ - "bp", - "weight", - "pulse", - "bmi", - "compliance", - "sleep", - "mood", -] as const satisfies ReadonlyArray; - -interface SectionTogglesProps { - /** - * Sections in render order — always the full `SECTION_ORDER` so the - * dialog shows the user every section that *could* be available, with - * disabled rows for ones that have no data in the current range. - */ - allSections: ReadonlyArray; - availability: SectionAvailability | null; - availabilityLoading: boolean; - prefs: DoctorReportPrefs; - onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void; -} - -const SECTION_LABEL_KEYS: Record = { - bp: "doctorReport.sections.bp", - weight: "doctorReport.sections.weight", - pulse: "doctorReport.sections.pulse", - bmi: "doctorReport.sections.bmi", - mood: "doctorReport.sections.mood", - compliance: "doctorReport.sections.compliance", - sleep: "doctorReport.sections.sleep", - // v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it); - // present so the `keyof DoctorReportPrefs` record stays exhaustive. - cycle: "doctorReport.sections.cycle", - // v1.17.1 — labs section; the flagship export panel drives the toggle, - // present here so the `keyof DoctorReportPrefs` record stays exhaustive. - labs: "doctorReport.sections.labs", -}; - -/** - * Exported only so the SSR component test in - * `__tests__/doctor-report-section-toggles.test.tsx` can render the - * toggle list outside the Radix `Dialog` portal. Treat as internal: - * the public surface is ``. - */ -export function SectionToggles({ - allSections, - availability, - availabilityLoading, - prefs, - onToggle, -}: SectionTogglesProps) { - const { t } = useTranslations(); - - // First open: show a quiet skeleton row instead of the full toggle - // list. Keeps the dialog from "flashing" between empty and populated - // states while the probe is in-flight. - if (availability === null && availabilityLoading) { - return ( -
-

- {t("doctorReport.sections.title")} -

-
- - -
-
- ); - } - - // v1.4.43 QoL (M4) — `availableCount` drives the empty-state hint. - // Pre-fix, an empty range filtered the list down to zero rows; now - // we always render every row with the empties disabled, so the - // empty-state guard kicks in only when *no* section has data. - const availableCount = availability - ? allSections.filter((key) => availability[key]).length - : 0; - - if (availability && availableCount === 0) { - return ( -
-

- {t("doctorReport.sections.title")} -

-

- {t("doctorReport.sections.empty")} -

-
- ); - } - - return ( -
-
-

- {t("doctorReport.sections.title")} -

- {availabilityLoading && ( - - )} -
-
    - {allSections.map((key) => { - const id = `dr-section-${key}`; - // v1.4.43 QoL (M4) — sections without data in the current - // range render disabled with a strike-through label and an - // explanatory tooltip; the user immediately understands - // why their PDF carries fewer sections than expected. - const isAvailable = availability?.[key] ?? false; - const unavailableHint = t("doctorReport.sections.unavailableHint"); - return ( -
  • - -
  • - ); - })} -
-
- ); -} 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",