feat(doctor-report): server-rendered PDF endpoint

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:52:43 +02:00
1 parent 54f689247e
commit 7479211531
7 files changed
+1312 -191

No files matched your search

+57
View File
@@ -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`).
@@ -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<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.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" }),
}),
);
});
});
+114
View File
@@ -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<PdfRequestBody | null> {
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);
}
+18 -191
View File
@@ -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<string, unknown>)?.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<string, unknown>)?.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<string, { min: number; max: number }> = {};
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);
});
@@ -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>): 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);
});
});
+269
View File
@@ -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<number, number>;
}
export interface DoctorReportData {
period: { days: number; since: string };
patient: {
username: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
};
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
stats: Record<string, DoctorReportStats>;
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
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<DoctorReportData> {
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<string, DoctorReportStats> = {};
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<string, DoctorReportCompliance> = {};
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<string, DoctorReportStats> = {};
const glucoseRanges: Record<string, { min: number; max: number }> = {};
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,
};
}
+505
View File
@@ -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, string | number>) => 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<string, string> = {
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<string, string | null> = {
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<number, string> = {
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<keyof typeof GLUCOSE_LABEL_KEYS> = [
"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<string, string> = {
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);
}