mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
54f689247e
commit
7479211531
7 files changed
+1312
-191
No files matched your search
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user