merge(reconcile): b3-doctorreport audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 02:20:45 +02:00
commit 95f4b5a263
15 files changed
+51 -1824

No files matched your search

-76
View File
@@ -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`).
@@ -76,7 +76,11 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"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<string> = [
"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",
];
@@ -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<typeof import("@/lib/modules/gate")>();
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<string, unknown>,
headers: Record<string, string> = {},
): NextRequest {
const baseHeaders: Record<string, string> = {
"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 }),
}),
);
});
});
@@ -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);
});
-177
View File
@@ -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<PdfRequestBody | null> {
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);
}
-110
View File
@@ -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<string, unknown> | 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<string, unknown> | 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);
});
@@ -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 }),
});
}
});
});
@@ -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);
@@ -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<string, unknown> {
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<string, unknown> | undefined;
expect(
root,
`doctorReport namespace missing in ${locale}.json`,
).toBeDefined();
const dialog = root!.dialog as Record<string, unknown> | 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<string, unknown> | undefined;
const sections = root!.sections as Record<string, unknown> | 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<string, string> })
.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<string, string> })
.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);
});
});
@@ -1,177 +0,0 @@
/**
* v1.4.43 QoL (M4) — `<SectionToggles>` 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 `<DoctorReportDialog>` 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SectionToggles> 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(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: true,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={{ ...DEFAULT_DOCTOR_REPORT_PREFS, compliance: true }}
onToggle={onToggle}
/>,
);
// 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(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
);
// 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(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
"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(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: false,
pulse: false,
bmi: false,
mood: false,
compliance: false,
sleep: false,
}}
availabilityLoading={false}
prefs={DEFAULT_DOCTOR_REPORT_PREFS}
onToggle={vi.fn()}
/>,
"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(
<SectionToggles
allSections={__test_SECTION_ORDER}
availability={{
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: true,
compliance: false,
sleep: true,
}}
availabilityLoading={false}
prefs={{ ...DEFAULT_DOCTOR_REPORT_PREFS, compliance: true }}
onToggle={vi.fn()}
/>,
);
// 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"');
});
});
@@ -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<keyof DoctorReportPrefs> = [
"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<void>;
}
/** 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<string>(defaultStartIso);
const [endDate, setEndDate] = useState<string>(todayIso);
const [practiceName, setPracticeName] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<DoctorReportPrefs>(
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<SectionAvailability | null>(
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<Partial<DoctorReportPrefs> | 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<SectionAvailability>(
"/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<HTMLFormElement>) {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("doctorReport.dialog.title")}</DialogTitle>
<DialogDescription>
{t("doctorReport.dialog.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="dr-start">
{t("doctorReport.dialog.startLabel")}
</Label>
<DateInput
id="dr-start"
value={startDate}
max={endDate || todayIso()}
onChange={(e) => {
setStartDate(e.target.value);
setError(null);
}}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dr-end">
{t("doctorReport.dialog.endLabel")}
</Label>
<DateInput
id="dr-end"
value={endDate}
min={startDate}
max={todayIso()}
onChange={(e) => {
setEndDate(e.target.value);
setError(null);
}}
required
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyPreset(90)}
>
{t("doctorReport.dialog.preset90d")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyPreset(180)}
>
{t("doctorReport.dialog.preset180d")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyPreset(365)}
>
{t("doctorReport.dialog.preset365d")}
</Button>
</div>
<div className="space-y-1.5">
<Label htmlFor="dr-practice">
{t("doctorReport.dialog.practiceLabel")}
</Label>
<Input
id="dr-practice"
type="text"
value={practiceName}
maxLength={PRACTICE_NAME_MAX_LENGTH}
placeholder={t("doctorReport.dialog.practicePlaceholder")}
onChange={(e) => setPracticeName(e.target.value)}
autoComplete="organization"
/>
</div>
{/* ── 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.
*/}
<SectionToggles
allSections={SECTION_ORDER}
availability={availability}
availabilityLoading={availabilityLoading}
prefs={prefs}
onToggle={toggleSection}
/>
{error && (
<p
role="alert"
className="text-destructive text-sm"
data-testid="doctor-report-dialog-error"
>
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("doctorReport.dialog.cancel")}
</Button>
<Button type="submit" disabled={submitting || !validation.ok}>
{submitting && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("doctorReport.dialog.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ────────────────────────────── 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<keyof DoctorReportPrefs>;
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<keyof DoctorReportPrefs>;
availability: SectionAvailability | null;
availabilityLoading: boolean;
prefs: DoctorReportPrefs;
onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void;
}
const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
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 `<DoctorReportDialog>`.
*/
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 (
<div
className="border-border bg-muted/30 space-y-2 rounded-lg border p-3"
data-testid="doctor-report-sections-loading"
>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<div className="space-y-2">
<Skeleton className="bg-muted h-9" />
<Skeleton className="bg-muted h-9" />
</div>
</div>
);
}
// 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 (
<div
className="border-border bg-muted/30 rounded-lg border p-3"
data-testid="doctor-report-sections-empty"
>
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
<p className="text-muted-foreground mt-1.5 text-xs leading-relaxed">
{t("doctorReport.sections.empty")}
</p>
</div>
);
}
return (
<div
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3"
data-testid="doctor-report-sections"
>
<div className="flex items-baseline justify-between gap-2">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("doctorReport.sections.title")}
</p>
{availabilityLoading && (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin motion-reduce:animate-none" />
)}
</div>
<ul className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{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 (
<li key={key} className="min-h-11">
<label
htmlFor={id}
title={!isAvailable ? unavailableHint : undefined}
className={`-mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 transition-colors ${
isAvailable
? "hover:bg-muted/50 cursor-pointer"
: "cursor-not-allowed opacity-60"
}`}
data-unavailable={!isAvailable ? "true" : undefined}
>
<span className="flex flex-col">
<span
className={`text-sm leading-tight font-medium ${
isAvailable ? "" : "text-muted-foreground"
}`}
>
{t(SECTION_LABEL_KEYS[key])}
</span>
{!isAvailable && (
<span className="text-muted-foreground text-[11px] leading-tight italic">
{unavailableHint}
</span>
)}
{key === "mood" && isAvailable && (
<span className="text-muted-foreground text-[11px] leading-tight">
{t("doctorReport.sections.moodSensitive")}
</span>
)}
</span>
<Switch
id={id}
data-testid={`doctor-report-section-${key}`}
size="sm"
disabled={!isAvailable}
checked={isAvailable && prefs[key]}
onCheckedChange={(checked) => onToggle(key, checked)}
/>
</label>
</li>
);
})}
</ul>
</div>
);
}
@@ -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",