mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(reconcile): b3-doctorreport audit findings
This commit is contained in:
commit
95f4b5a263
15 files changed
+51
-1824
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user