From 74792115311d41e7161b940dfbec5732d675e7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 3 May 2026 21:41:42 +0200 Subject: [PATCH] feat(doctor-report): server-rendered PDF endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `POST /api/doctor-report/pdf` that returns a fully rendered PDF (`Content-Type: application/pdf`) using the same jsPDF document the web client builds locally, just executed server-side. Locale resolves from explicit body param → `Accept-Language` → `de` fallback. The existing `POST /api/doctor-report` JSON endpoint is unchanged; both endpoints share `collectDoctorReportData()` so any future change to the aggregation lands in one place. The render core (`doctor-report-pdf-core`) is isomorphic — same code paths run in browser jsPDF and Node jsPDF. Rate-limit (10/h per user) is shared between the two endpoints so the quota cannot be doubled by mixing JSON and PDF requests. Tests: 6 core tests (PDF header bytes, size, determinism, DE+EN, empty data, jsPDF doc shape) + 5 route tests (401, 429, 200 happy-path with content-type/disposition, default `days=90`, Accept-Language fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/doctor-report.md | 57 ++ .../doctor-report/__tests__/pdf-route.test.ts | 186 +++++++ src/app/api/doctor-report/pdf/route.ts | 114 ++++ src/app/api/doctor-report/route.ts | 209 +------- .../__tests__/doctor-report-pdf-core.test.ts | 163 ++++++ src/lib/doctor-report-data.ts | 269 ++++++++++ src/lib/doctor-report-pdf-core.ts | 505 ++++++++++++++++++ 7 files changed, 1312 insertions(+), 191 deletions(-) create mode 100644 docs/doctor-report.md create mode 100644 src/app/api/doctor-report/__tests__/pdf-route.test.ts create mode 100644 src/app/api/doctor-report/pdf/route.ts create mode 100644 src/lib/__tests__/doctor-report-pdf-core.test.ts create mode 100644 src/lib/doctor-report-data.ts create mode 100644 src/lib/doctor-report-pdf-core.ts diff --git a/docs/doctor-report.md b/docs/doctor-report.md new file mode 100644 index 000000000..bb69e84b5 --- /dev/null +++ b/docs/doctor-report.md @@ -0,0 +1,57 @@ +# 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`. + +## Implementation notes + +- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer. + jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid + `%PDF-` byte stream in both environments, so we deliberately avoid headless + Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low. +- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the + raw `jsPDF` instance so the existing settings-page download flow (which + calls `doc.save(...)`) keeps working unchanged. +- Server-side translations come from `messages/{de,en}.json` via + `getServerTranslator()` (`src/lib/i18n/server-translator.ts`). diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts new file mode 100644 index 000000000..62395201c --- /dev/null +++ b/src/app/api/doctor-report/__tests__/pdf-route.test.ts @@ -0,0 +1,186 @@ +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() }, + user: { findUnique: 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(), +})); + +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"; + +function makeRequest( + body?: Record, + headers: Record = {}, +): NextRequest { + const baseHeaders: Record = { + "content-type": "application/json", + ...headers, + }; + return new NextRequest("http://localhost/api/doctor-report/pdf", { + method: "POST", + headers: baseHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, + user: { id: "user-1", role: "USER" as const }, +}; + +function setEmptyDataMocks() { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "marc", + dateOfBirth: null, + gender: null, + heightCm: null, + glucoseUnit: null, + thresholdsJson: null, + } as never); +} + +describe("POST /api/doctor-report/pdf", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + 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" }), + }), + ); + }); +}); diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts new file mode 100644 index 000000000..7971299fc --- /dev/null +++ b/src/app/api/doctor-report/pdf/route.ts @@ -0,0 +1,114 @@ +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, + normaliseDays, +} from "@/lib/doctor-report-data"; +import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; +import { locales, type Locale } from "@/lib/i18n/config"; + +/** + * 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 days = normaliseDays(body?.days); + const locale = resolveLocale( + body?.locale, + request.headers.get("accept-language"), + ); + + const data = await collectDoctorReportData(user.id, days); + + const { t } = getServerTranslator(locale); + const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale }); + + await auditLog("doctor-report.pdf.generate", { + userId: user.id, + ipAddress: getClientIp(request), + details: { days, locale }, + }); + + annotate({ meta: { report_days: days, locale, bytes: pdfBytes.byteLength } }); + + 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; + locale?: unknown; +} + +/** + * Best-effort JSON body parse. Returns `null` if the request had no body or + * a non-JSON content type — both are acceptable for this endpoint. + */ +async function readOptionalJsonBody( + request: NextRequest, +): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) return null; + try { + return (await request.json()) as PdfRequestBody; + } catch { + return null; + } +} + +/** + * Resolve the active locale: explicit body override wins, then a parsed + * `Accept-Language`, then a hard fallback to German (the primary HealthLog + * locale for the medical context). + */ +function resolveLocale( + bodyLocale: unknown, + acceptLanguage: string | null, +): Locale { + if ( + typeof bodyLocale === "string" && + (locales as readonly string[]).includes(bodyLocale) + ) { + return bodyLocale as Locale; + } + if (!acceptLanguage) return "de"; + // parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise + // "en" — matching the broader app's i18n behaviour for present headers. + return parseLocaleFromAcceptLanguage(acceptLanguage); +} diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts index 3440f89b5..ed936e3e4 100644 --- a/src/app/api/doctor-report/route.ts +++ b/src/app/api/doctor-report/route.ts @@ -1,16 +1,18 @@ -import { prisma } from "@/lib/db"; 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 { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { - getEffectiveRange, - type ThresholdOverridesJson, -} from "@/lib/analytics/effective-range"; -import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose"; -import type { GlucoseContext } from "@/generated/prisma/client"; + collectDoctorReportData, + normaliseDays, +} from "@/lib/doctor-report-data"; /** * Collect data for doctor report PDF generation (client-side). @@ -20,203 +22,28 @@ 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); + 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); - if (jsonError) return jsonError; - const rawDays = (body as Record)?.days; - const days = - typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365 - ? rawDays - : 90; - const since = new Date(); - since.setDate(since.getDate() - days); - const userId = user.id; - - const [measurements, medications, intakeEvents, moodEntries, userProfile] = - await Promise.all([ - prisma.measurement.findMany({ - where: { userId, measuredAt: { gte: since } }, - orderBy: { measuredAt: "asc" }, - }), - prisma.medication.findMany({ - where: { userId, active: true }, - include: { schedules: true }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId, scheduledFor: { gte: since } }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "asc" }, - }), - prisma.moodEntry.findMany({ - where: { userId, moodLoggedAt: { gte: since } }, - orderBy: { moodLoggedAt: "asc" }, - }), - prisma.user.findUnique({ - where: { id: userId }, - select: { - username: true, - dateOfBirth: true, - gender: true, - heightCm: true, - glucoseUnit: true, - thresholdsJson: true, - }, - }), - ]); - - // Group measurements by type - const byType: Record< - string, - Array<{ value: number; measuredAt: string }> - > = {}; - for (const m of measurements) { - if (!byType[m.type]) byType[m.type] = []; - byType[m.type].push({ - value: m.value, - measuredAt: m.measuredAt.toISOString(), - }); - } - - // Calculate stats per type - const stats: Record< - string, - { avg: number; min: number; max: number; count: number; latest: number } - > = {}; - for (const [type, entries] of Object.entries(byType)) { - const values = entries.map((e) => e.value); - stats[type] = { - avg: values.reduce((a, b) => a + b, 0) / values.length, - min: Math.min(...values), - max: Math.max(...values), - count: values.length, - latest: values[values.length - 1], - }; - } - - // Medication compliance - const complianceByMed: Record< - string, - { total: number; taken: number; skipped: number; missed: number } - > = {}; - for (const event of intakeEvents) { - const name = event.medication.name; - if (!complianceByMed[name]) { - complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 }; - } - complianceByMed[name].total++; - if (event.takenAt) { - complianceByMed[name].taken++; - } else if (event.skipped) { - complianceByMed[name].skipped++; - } else { - complianceByMed[name].missed++; - } - } - - // Mood summary - const moodScores = moodEntries.map((e) => e.score); - const moodSummary = - moodScores.length > 0 - ? { - avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length, - min: Math.min(...moodScores), - max: Math.max(...moodScores), - count: moodScores.length, - distribution: { - 1: moodScores.filter((s) => s === 1).length, - 2: moodScores.filter((s) => s === 2).length, - 3: moodScores.filter((s) => s === 3).length, - 4: moodScores.filter((s) => s === 4).length, - 5: moodScores.filter((s) => s === 5).length, - }, - } - : null; - - // BMI - const weightStats = stats.WEIGHT; - const bmi = - weightStats && userProfile?.heightCm - ? weightStats.latest / (userProfile.heightCm / 100) ** 2 - : null; + const days = normaliseDays((body as Record)?.days); + const data = await collectDoctorReportData(user.id, days); await auditLog("doctor-report.generate", { - userId, + userId: user.id, ipAddress: getClientIp(request), details: { days }, }); annotate({ meta: { report_days: days } }); - // Per-context glucose stats (canonical mg/dL) and effective ranges. - const glucoseContexts: GlucoseContext[] = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", - ]; - const glucoseStats: Record< - string, - { avg: number; min: number; max: number; count: number; latest: number } - > = {}; - const glucoseRanges: Record = {}; - const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE"); - const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null; - const profileForRange = { - heightCm: userProfile?.heightCm ?? null, - dateOfBirth: userProfile?.dateOfBirth ?? null, - gender: userProfile?.gender ?? null, - }; - for (const ctx of glucoseContexts) { - const rows = glucoseRows.filter((m) => m.glucoseContext === ctx); - if (rows.length === 0) continue; - const values = rows.map((r) => r.value); - glucoseStats[ctx] = { - avg: values.reduce((a, b) => a + b, 0) / values.length, - min: Math.min(...values), - max: Math.max(...values), - count: values.length, - latest: values[values.length - 1], - }; - const eff = getEffectiveRange( - thresholdMetricForContext(ctx), - profileForRange, - overrides, - ); - if (eff.range) { - glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax }; - } - } - - return apiSuccess({ - period: { days, since: since.toISOString() }, - patient: { - username: userProfile?.username ?? null, - dateOfBirth: userProfile?.dateOfBirth ?? null, - gender: userProfile?.gender ?? null, - heightCm: userProfile?.heightCm ?? null, - }, - measurements: byType, - stats, - glucoseStats, - glucoseRanges, - glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null), - bmi: bmi ? Math.round(bmi * 10) / 10 : null, - compliance: complianceByMed, - medications: medications.map((m) => ({ - name: m.name, - dose: m.dose, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - })), - })), - mood: moodSummary, - }); + return apiSuccess(data); }); diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts new file mode 100644 index 000000000..b8df103b1 --- /dev/null +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { + buildDoctorReportPdfDocument, + renderDoctorReportPdfBytes, +} from "../doctor-report-pdf-core"; +import type { DoctorReportData } from "../doctor-report-data"; +import { getServerTranslator } from "../i18n/server-translator"; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +function makeData(overrides?: Partial): DoctorReportData { + return { + period: { days: 90, since: "2026-02-02T00:00:00.000Z" }, + patient: { + username: "marc", + dateOfBirth: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + }, + measurements: { + WEIGHT: [ + { value: 80, measuredAt: "2026-02-10T08:00:00.000Z" }, + { value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + }, + stats: { + WEIGHT: { avg: 79.75, min: 79.5, max: 80, count: 2, latest: 79.5 }, + BLOOD_PRESSURE_SYS: { + avg: 122, + min: 118, + max: 128, + count: 5, + latest: 120, + }, + BLOOD_PRESSURE_DIA: { + avg: 78, + min: 72, + max: 82, + count: 5, + latest: 78, + }, + PULSE: { avg: 65, min: 58, max: 72, count: 5, latest: 64 }, + }, + glucoseStats: { + FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 }, + }, + glucoseRanges: { FASTING: { min: 70, max: 99 } }, + glucoseUnit: "mg/dL", + bmi: 24.1, + compliance: { + Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 }, + }, + medications: [ + { + name: "Ramipril", + dose: "5mg", + schedules: [ + { windowStart: "08:00", windowEnd: "09:00", label: "Morning" }, + ], + }, + ], + mood: { + avg: 3.6, + min: 2, + max: 5, + count: 30, + distribution: { 1: 0, 2: 3, 3: 9, 4: 14, 5: 4 }, + }, + ...overrides, + }; +} + +describe("renderDoctorReportPdfBytes", () => { + it("returns a Uint8Array starting with the %PDF- header", () => { + const { t } = getServerTranslator("de"); + const bytes = renderDoctorReportPdfBytes(makeData(), { + t, + locale: "de", + now: FIXED_NOW, + }); + expect(bytes).toBeInstanceOf(Uint8Array); + const header = String.fromCharCode(...bytes.slice(0, 5)); + expect(header).toBe("%PDF-"); + }); + + it("produces a non-trivial document (> 1 KB)", () => { + const { t } = getServerTranslator("de"); + const bytes = renderDoctorReportPdfBytes(makeData(), { + t, + locale: "de", + now: FIXED_NOW, + }); + expect(bytes.byteLength).toBeGreaterThan(1024); + }); + + it("is structurally deterministic for the same input + fixed timestamp", () => { + // jsPDF embeds a per-invocation file-id in the trailer (random UUID-like + // hex). The visible content is fully determined by the inputs, so we + // assert byte-length equality + identical content sections instead of a + // strict bytewise compare. + const { t } = getServerTranslator("de"); + const a = renderDoctorReportPdfBytes(makeData(), { + t, + locale: "de", + now: FIXED_NOW, + }); + const b = renderDoctorReportPdfBytes(makeData(), { + t, + locale: "de", + now: FIXED_NOW, + }); + expect(a.byteLength).toBe(b.byteLength); + // Compare the first 95% of the document — file-id and trailer differ. + const cmp = Math.floor(a.byteLength * 0.95); + expect( + Buffer.from(a.slice(0, cmp)).equals(Buffer.from(b.slice(0, cmp))), + ).toBe(true); + }); + + it("renders both DE and EN locales without errors", () => { + const de = renderDoctorReportPdfBytes(makeData(), { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const en = renderDoctorReportPdfBytes(makeData(), { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + expect(de.byteLength).toBeGreaterThan(1024); + expect(en.byteLength).toBeGreaterThan(1024); + }); + + it("handles empty stats / no mood without throwing", () => { + const empty = makeData({ + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + compliance: {}, + mood: null, + bmi: null, + }); + const bytes = renderDoctorReportPdfBytes(empty, { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + const header = String.fromCharCode(...bytes.slice(0, 5)); + expect(header).toBe("%PDF-"); + }); +}); + +describe("buildDoctorReportPdfDocument", () => { + it("returns a jsPDF doc with at least one page", () => { + const doc = buildDoctorReportPdfDocument(makeData(), { + t: getServerTranslator("de").t, + locale: "de", + now: FIXED_NOW, + }); + expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts new file mode 100644 index 000000000..eda6d846d --- /dev/null +++ b/src/lib/doctor-report-data.ts @@ -0,0 +1,269 @@ +/** + * Server-side aggregator for doctor-report data. + * + * Single source of truth for the aggregated payload consumed by both + * `/api/doctor-report` (JSON, client renders PDF) and + * `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints + * structurally identical so visual parity between client- and server-rendered + * PDFs is guaranteed by construction, not by drift-prone copy-paste. + */ + +import { prisma } from "@/lib/db"; +import { + getEffectiveRange, + type ThresholdOverridesJson, +} from "@/lib/analytics/effective-range"; +import { + resolveGlucoseUnit, + thresholdMetricForContext, + type GlucoseUnit, +} from "@/lib/glucose"; +import type { GlucoseContext } from "@/generated/prisma/client"; + +export interface DoctorReportStats { + avg: number; + min: number; + max: number; + count: number; + latest: number; +} + +export interface DoctorReportCompliance { + total: number; + taken: number; + skipped: number; + missed: number; +} + +export interface DoctorReportMood { + avg: number; + min: number; + max: number; + count: number; + distribution: Record; +} + +export interface DoctorReportData { + period: { days: number; since: string }; + patient: { + username: string | null; + dateOfBirth: string | null; + gender: string | null; + heightCm: number | null; + }; + measurements: Record>; + stats: Record; + glucoseStats: Record; + glucoseRanges: Record; + glucoseUnit: GlucoseUnit; + bmi: number | null; + compliance: Record; + medications: Array<{ + name: string; + dose: string; + schedules: Array<{ + windowStart: string; + windowEnd: string; + label: string | null; + }>; + }>; + mood: DoctorReportMood | null; +} + +const GLUCOSE_CONTEXTS: GlucoseContext[] = [ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", +]; + +/** + * Validate and normalise the requested reporting window. + * Accepts an unknown value (typically `body.days`); falls back to 90. + */ +export function normaliseDays(value: unknown, fallback = 90): number { + if ( + typeof value === "number" && + Number.isInteger(value) && + value >= 1 && + value <= 365 + ) { + return value; + } + return fallback; +} + +/** + * Aggregate the doctor-report payload for a user over the last `days` days. + * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. + */ +export async function collectDoctorReportData( + userId: string, + days: number, +): Promise { + const since = new Date(); + since.setDate(since.getDate() - days); + + const [measurements, medications, intakeEvents, moodEntries, userProfile] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId, measuredAt: { gte: since } }, + orderBy: { measuredAt: "asc" }, + }), + prisma.medication.findMany({ + where: { userId, active: true }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, scheduledFor: { gte: since } }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "asc" }, + }), + prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + username: true, + dateOfBirth: true, + gender: true, + heightCm: true, + glucoseUnit: true, + thresholdsJson: true, + }, + }), + ]); + + // Group measurements by type. + const byType: Record< + string, + Array<{ value: number; measuredAt: string }> + > = {}; + for (const m of measurements) { + if (!byType[m.type]) byType[m.type] = []; + byType[m.type].push({ + value: m.value, + measuredAt: m.measuredAt.toISOString(), + }); + } + + // Per-type stats. + const stats: Record = {}; + for (const [type, entries] of Object.entries(byType)) { + const values = entries.map((e) => e.value); + stats[type] = { + avg: values.reduce((a, b) => a + b, 0) / values.length, + min: Math.min(...values), + max: Math.max(...values), + count: values.length, + latest: values[values.length - 1], + }; + } + + // Medication compliance. + const compliance: Record = {}; + for (const event of intakeEvents) { + const name = event.medication.name; + if (!compliance[name]) { + compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 }; + } + compliance[name].total++; + if (event.takenAt) { + compliance[name].taken++; + } else if (event.skipped) { + compliance[name].skipped++; + } else { + compliance[name].missed++; + } + } + + // Mood summary. + const moodScores = moodEntries.map((e) => e.score); + const mood: DoctorReportMood | null = + moodScores.length > 0 + ? { + avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length, + min: Math.min(...moodScores), + max: Math.max(...moodScores), + count: moodScores.length, + distribution: { + 1: moodScores.filter((s) => s === 1).length, + 2: moodScores.filter((s) => s === 2).length, + 3: moodScores.filter((s) => s === 3).length, + 4: moodScores.filter((s) => s === 4).length, + 5: moodScores.filter((s) => s === 5).length, + }, + } + : null; + + // BMI from latest weight + profile height. + const weightStats = stats.WEIGHT; + const bmiRaw = + weightStats && userProfile?.heightCm + ? weightStats.latest / (userProfile.heightCm / 100) ** 2 + : null; + const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null; + + // Per-context glucose stats + effective ranges (canonical mg/dL). + const glucoseStats: Record = {}; + const glucoseRanges: Record = {}; + const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE"); + const overrides = (userProfile?.thresholdsJson ?? + null) as ThresholdOverridesJson | null; + const profileForRange = { + heightCm: userProfile?.heightCm ?? null, + dateOfBirth: userProfile?.dateOfBirth ?? null, + gender: userProfile?.gender ?? null, + }; + for (const ctx of GLUCOSE_CONTEXTS) { + const rows = glucoseRows.filter((m) => m.glucoseContext === ctx); + if (rows.length === 0) continue; + const values = rows.map((r) => r.value); + glucoseStats[ctx] = { + avg: values.reduce((a, b) => a + b, 0) / values.length, + min: Math.min(...values), + max: Math.max(...values), + count: values.length, + latest: values[values.length - 1], + }; + const eff = getEffectiveRange( + thresholdMetricForContext(ctx), + profileForRange, + overrides, + ); + if (eff.range) { + glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax }; + } + } + + return { + period: { days, since: since.toISOString() }, + patient: { + username: userProfile?.username ?? null, + dateOfBirth: userProfile?.dateOfBirth + ? userProfile.dateOfBirth.toISOString() + : null, + gender: userProfile?.gender ?? null, + heightCm: userProfile?.heightCm ?? null, + }, + measurements: byType, + stats, + glucoseStats, + glucoseRanges, + glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null), + bmi, + compliance, + medications: medications.map((m) => ({ + name: m.name, + dose: m.dose, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label, + })), + })), + mood, + }; +} diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts new file mode 100644 index 000000000..29e652d4b --- /dev/null +++ b/src/lib/doctor-report-pdf-core.ts @@ -0,0 +1,505 @@ +/** + * Isomorphic PDF renderer for the doctor report. + * + * Runs identically in the browser (settings page download) and in Node + * (server-rendered `/api/doctor-report/pdf` endpoint). All locale-sensitive + * strings and number/date formatting are driven by the injected + * `{ t, locale }` so DE and EN output match the user's UI language. + * + * jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid + * `%PDF-` byte stream in both environments. + */ +import { jsPDF } from "jspdf"; +import autoTable from "jspdf-autotable"; +import { makeFormatters } from "./format-locale"; +import type { Locale } from "./i18n/config"; +import { convertGlucose, resolveGlucoseUnit } from "./glucose"; +import type { DoctorReportData } from "./doctor-report-data"; + +type T = (key: string, params?: Record) => string; + +export interface DoctorReportRenderOptions { + t: T; + locale: Locale; + /** + * Optional fixed timestamp for "createdOn"/footer. Useful for deterministic + * tests; defaults to `new Date()`. + */ + now?: Date; +} + +const TYPE_LABEL_KEYS: Record = { + WEIGHT: "doctorReport.typeWeight", + BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", + BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", + PULSE: "doctorReport.typePulse", + BODY_FAT: "doctorReport.typeBodyFat", + SLEEP_DURATION: "doctorReport.typeSleep", + ACTIVITY_STEPS: "doctorReport.typeSteps", +}; + +const TYPE_UNIT_KEYS: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_FAT: "%", + SLEEP_DURATION: "h", + ACTIVITY_STEPS: null, // translated unit +}; + +const MOOD_LABEL_KEYS: Record = { + 1: "doctorReport.moodAwful", + 2: "doctorReport.moodBad", + 3: "doctorReport.moodNeutral", + 4: "doctorReport.moodGood", + 5: "doctorReport.moodGreat", +}; + +const GLUCOSE_LABEL_KEYS = { + FASTING: "doctorReport.typeGlucoseFasting", + POSTPRANDIAL: "doctorReport.typeGlucosePostprandial", + RANDOM: "doctorReport.typeGlucoseRandom", + BEDTIME: "doctorReport.typeGlucoseBedtime", +} as const; + +const DEFAULT_GLUCOSE_RANGES = { + FASTING: { min: 70, max: 99 }, + POSTPRANDIAL: { min: 70, max: 140 }, + RANDOM: { min: 70, max: 140 }, + BEDTIME: { min: 90, max: 150 }, +} as const; + +const GLUCOSE_CONTEXTS: Array = [ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", +]; + +function getBmiClassificationKey(bmi: number): string { + if (bmi < 18.5) return "doctorReport.bmiUnderweight"; + if (bmi < 25) return "doctorReport.bmiNormal"; + if (bmi < 30) return "doctorReport.bmiOverweight"; + if (bmi < 35) return "doctorReport.bmiObeseGrade1"; + if (bmi < 40) return "doctorReport.bmiObeseGrade2"; + return "doctorReport.bmiObeseGrade3"; +} + +function getBpClassificationKey(sys: number, dia: number): string { + if (sys < 120 && dia < 80) return "doctorReport.bpOptimal"; + if (sys < 130 && dia < 85) return "doctorReport.bpNormal"; + if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal"; + if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1"; + if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2"; + return "doctorReport.bpHypertensionGrade3"; +} + +/** + * Render the doctor report into a `jsPDF` instance. + * + * Used internally by both the client wrapper (which calls `.save()` on the + * returned doc) and the server renderer (which calls `.output("arraybuffer")` + * via `renderDoctorReportPdfBytes`). + */ +export function buildDoctorReportPdfDocument( + data: DoctorReportData, + options: DoctorReportRenderOptions, +): jsPDF { + const { t, locale, now = new Date() } = options; + const formatters = makeFormatters(locale); + const num = (value: number, decimals = 1) => + formatters.number(value, decimals); + const fmtDate = (iso: string) => formatters.date(iso); + + const unitFor = (type: string): string => { + const staticUnit = TYPE_UNIT_KEYS[type]; + if (staticUnit === null && type === "ACTIVITY_STEPS") { + return t("doctorReport.unitSteps"); + } + return staticUnit ?? ""; + }; + + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 20; + let y = margin; + + doc.setFontSize(22); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.title"), margin, y); + y += 8; + + doc.setFontSize(10); + doc.setFont("helvetica", "normal"); + doc.setTextColor(100, 100, 100); + doc.text(t("doctorReport.subtitle"), margin, y); + y += 6; + + doc.setDrawColor(200, 200, 200); + doc.setLineWidth(0.5); + doc.line(margin, y, pageWidth - margin, y); + y += 8; + + doc.setFontSize(9); + doc.setTextColor(80, 80, 80); + const patientInfo: string[] = []; + if (data.patient.username) { + patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`); + } + if (data.patient.dateOfBirth) { + patientInfo.push( + `${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`, + ); + } + if (data.patient.gender) { + const genderKeys: Record = { + MALE: "doctorReport.genderMale", + FEMALE: "doctorReport.genderFemale", + }; + const genderKey = + genderKeys[data.patient.gender] ?? "doctorReport.genderOther"; + patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`); + } + if (data.patient.heightCm) { + patientInfo.push( + `${t("doctorReport.height")}: ${data.patient.heightCm} cm`, + ); + } + patientInfo.push( + `${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(now.toISOString())}`, + ); + patientInfo.push( + `${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`, + ); + + for (const line of patientInfo) { + doc.text(line, margin, y); + y += 4.5; + } + y += 4; + + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.vitalsTitle"), margin, y); + y += 6; + + const vitalRows: string[][] = []; + const vitalTypes = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + ]; + + for (const type of vitalTypes) { + const s = data.stats[type]; + if (!s) continue; + const unit = unitFor(type); + vitalRows.push([ + t(TYPE_LABEL_KEYS[type] ?? ""), + `${num(s.latest)} ${unit}`.trim(), + `${num(s.avg)} ${unit}`.trim(), + num(s.min), + num(s.max), + String(s.count), + ]); + } + + // Per-context glucose rows. Values stored canonically in mg/dL — convert to + // the user's display unit. Reference ranges come from the server-side + // `getEffectiveRange()` (already baked into `data.glucoseRanges`). + const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); + for (const ctx of GLUCOSE_CONTEXTS) { + const s = data.glucoseStats?.[ctx]; + if (!s) continue; + const conv = (v: number) => convertGlucose(v, glucoseUnit); + vitalRows.push([ + t(GLUCOSE_LABEL_KEYS[ctx]), + `${num(conv(s.latest))} ${glucoseUnit}`.trim(), + `${num(conv(s.avg))} ${glucoseUnit}`.trim(), + num(conv(s.min)), + num(conv(s.max)), + String(s.count), + ]); + } + + if (vitalRows.length > 0) { + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.colParameter"), + t("doctorReport.colCurrent"), + t("doctorReport.colAvgPeriod"), + t("doctorReport.colMin"), + t("doctorReport.colMax"), + t("doctorReport.colN"), + ], + ], + body: vitalRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { left: margin, right: margin }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + + const sysStat = data.stats.BLOOD_PRESSURE_SYS; + const diaStat = data.stats.BLOOD_PRESSURE_DIA; + if (sysStat && diaStat) { + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.bpClassificationTitle"), margin, y); + y += 5; + + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg)); + doc.text( + `${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`, + margin, + y, + ); + y += 8; + } + + if (data.bmi) { + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.bmiTitle"), margin, y); + y += 5; + + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + doc.text( + t("doctorReport.bmiRow", { + bmi: num(data.bmi, 1), + class: t(getBmiClassificationKey(data.bmi)), + }), + margin, + y, + ); + y += 8; + } + + // Glucose classification — one line per logged context. + const loggedGlucose = GLUCOSE_CONTEXTS.filter( + (ctx) => data.glucoseStats?.[ctx], + ); + if (loggedGlucose.length > 0) { + if (y > 240) { + doc.addPage(); + y = margin; + } + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y); + y += 5; + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + for (const ctx of loggedGlucose) { + const s = data.glucoseStats[ctx]; + if (!s) continue; + const range = data.glucoseRanges?.[ctx] ?? DEFAULT_GLUCOSE_RANGES[ctx]; + const conv = (v: number) => convertGlucose(v, glucoseUnit); + const inRange = s.avg >= range.min && s.avg <= range.max; + const classKey = inRange + ? "doctorReport.glucoseInTarget" + : s.avg < range.min + ? "doctorReport.glucoseBelowTarget" + : "doctorReport.glucoseAboveTarget"; + doc.text( + t("doctorReport.glucoseRow", { + label: t(GLUCOSE_LABEL_KEYS[ctx]), + avg: num(conv(s.avg), 1), + unit: glucoseUnit, + rangeMin: num(conv(range.min)), + rangeMax: num(conv(range.max)), + class: t(classKey), + }), + margin, + y, + ); + y += 5; + } + y += 3; + } + + const complianceEntries = Object.entries(data.compliance); + if (complianceEntries.length > 0) { + if (y > 240) { + doc.addPage(); + y = margin; + } + + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.complianceTitle"), margin, y); + y += 6; + + const compRows = complianceEntries.map(([name, c]) => { + const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—"; + return [ + name, + String(c.taken), + String(c.skipped), + String(c.missed), + String(c.total), + rate, + ]; + }); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.colMedication"), + t("doctorReport.colTaken"), + t("doctorReport.colSkipped"), + t("doctorReport.colMissed"), + t("doctorReport.colTotal"), + t("doctorReport.colComplianceRate"), + ], + ], + body: compRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { left: margin, right: margin }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + + if (data.mood) { + if (y > 240) { + doc.addPage(); + y = margin; + } + + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.moodTitle"), margin, y); + y += 6; + + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + doc.text( + t("doctorReport.moodSummary", { + avg: num(data.mood.avg, 1), + count: data.mood.count, + min: num(data.mood.min, 0), + max: num(data.mood.max, 0), + }), + margin, + y, + ); + y += 6; + + const distRows = Object.entries(data.mood.distribution).map( + ([score, count]) => [ + t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"), + String(count), + data.mood && data.mood.count > 0 + ? `${num((count / data.mood.count) * 100, 1)}%` + : "0%", + ], + ); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.colMood"), + t("doctorReport.colCount"), + t("doctorReport.colShare"), + ], + ], + body: distRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + margin: { left: margin, right: margin }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + + const addFooter = (pageDoc: jsPDF) => { + const pageHeight = pageDoc.internal.pageSize.getHeight(); + pageDoc.setFontSize(7); + pageDoc.setFont("helvetica", "italic"); + pageDoc.setTextColor(140, 140, 140); + pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14); + pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10); + pageDoc.text( + t("doctorReport.footerSource", { + timestamp: formatters.dateTime(now), + }), + margin, + pageHeight - 6, + ); + }; + + const totalPages = doc.getNumberOfPages(); + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + addFooter(doc); + } + + return doc; +} + +/** + * Render the doctor report and return the PDF as a `Uint8Array`. + * Isomorphic — works in browser and Node. + */ +export function renderDoctorReportPdfBytes( + data: DoctorReportData, + options: DoctorReportRenderOptions, +): Uint8Array { + const doc = buildDoctorReportPdfDocument(data, options); + const ab = doc.output("arraybuffer"); + return new Uint8Array(ab); +}