feat(doctor-report): practice name on cover page (persisted as user preference)

Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.

- New optional free-text field "Praxis / clinic name" in the export
  dialog. The dialog pre-fills it with the user's last-used value so
  recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
  column (migration `0031_user_last_report_practice_name`). The value
  is updated only on a successful POST that supplied a non-empty
  string — passing `null` does NOT clear the stored preference so the
  value sticks across years of follow-ups. Best-effort write: a DB
  failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
  ASCII C0 + DEL controls (which jsPDF can't render), collapses
  whitespace, and hard-caps at 120 characters. Defends the PDF layout
  from runaway input + protects the PDF text stream from
  control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
  omitted entirely when `null` so the cover stays compact for
  self-archive use. Both client (`doctor-report-pdf.ts`) and server
  (`doctor-report-pdf-core.ts`) renderers carry the change so the
  visual output matches whichever path the user takes (browser
  download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
  pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
  boolean` (NOT the value itself — practice names are personal and we
  already store one canonical copy on the user record).

i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.

Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.

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-09 21:36:47 +02:00
1 parent d692119454
commit 28467b2590
15 files changed
+249 -19

No files matched your search

+4 -1
View File
@@ -121,11 +121,14 @@
"glucoseInTarget": "im Ziel",
"glucoseBelowTarget": "unter Ziel",
"glucoseAboveTarget": "über Ziel",
"practiceLabel": "Praxis",
"dialog": {
"title": "Arztbericht (PDF)",
"description": "Lege den Auswertungszeitraum fest — er erscheint auf dem Deckblatt.",
"description": "Lege den Auswertungszeitraum fest und (optional) den Praxisnamen — beide erscheinen auf dem Deckblatt.",
"startLabel": "Von",
"endLabel": "Bis",
"practiceLabel": "Praxis / Klinik (optional)",
"practicePlaceholder": "z. B. Dr. Müller — Hausarztpraxis",
"preset90d": "Letzte 90 Tage",
"preset180d": "Letzte 6 Monate",
"preset365d": "Letzte 12 Monate",
+4 -1
View File
@@ -121,11 +121,14 @@
"glucoseInTarget": "in target",
"glucoseBelowTarget": "below target",
"glucoseAboveTarget": "above target",
"practiceLabel": "Practice",
"dialog": {
"title": "Doctor report (PDF)",
"description": "Pick the reporting period; it will be printed on the cover.",
"description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.",
"startLabel": "Start date",
"endLabel": "End date",
"practiceLabel": "Practice / clinic name (optional)",
"practicePlaceholder": "e.g. Dr. Müller — Family practice",
"preset90d": "Last 90 days",
"preset180d": "Last 6 months",
"preset365d": "Last 12 months",
@@ -0,0 +1,20 @@
-- v1.4.15 Phase B6: doctor-report v2 — practice / clinic name preference.
--
-- The doctor-report cover used to be impersonal: title, subtitle, separator.
-- Marc asked for the addressee (his GP's practice name) to be visible at
-- first glance so the printout doubles as an opening line ("Praxis Dr. X —
-- here is what I tracked since the last visit").
--
-- We persist the most-recent value the user typed into the export dialog
-- so the input is pre-filled next time. Stored as a plain TEXT column —
-- this is not PII a third party would care about and the user already has
-- write-access to it, but the field is sanitised + length-capped upstream
-- (`sanitisePracticeName()` in `src/lib/doctor-report-data.ts`) before any
-- value lands here.
--
-- The migration is a no-op for existing rows: the column is nullable with
-- no default; users who never open the doctor-report keep `NULL` and the
-- cover line is omitted entirely.
ALTER TABLE "users"
ADD COLUMN "last_report_practice_name" TEXT;
+6
View File
@@ -97,6 +97,12 @@ model User {
// Display name (optional override of username for greetings).
displayName String? @map("display_name")
// Doctor-report personalization (v1.4.15 phase B6).
// Persisted between report exports so the dialog can pre-fill the last-used
// practice / clinic name. Free-text, hard-capped to 120 chars upstream by
// `sanitisePracticeName()`. Null = user has never set one.
lastReportPracticeName String? @map("last_report_practice_name")
// Relations
passkeys Passkey[]
sessions Session[]
+1
View File
@@ -23,5 +23,6 @@ export const GET = apiHandler(async () => {
onboardingTourCompleted: user.onboardingTourCompleted,
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
glucoseUnit: user.glucoseUnit ?? null,
lastReportPracticeName: user.lastReportPracticeName ?? null,
});
});
@@ -9,7 +9,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
@@ -241,4 +241,30 @@ describe("POST /api/doctor-report/pdf", () => {
);
});
it("persists practiceName as a user preference and includes it in audit", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await POST(
makeRequest({ days: 30, practiceName: "Praxis Dr. Müller" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { lastReportPracticeName: "Praxis Dr. Müller" },
});
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ practiceNameProvided: true }),
}),
);
});
});
+19 -1
View File
@@ -7,7 +7,9 @@ import { checkRateLimit } from "@/lib/rate-limit";
import {
collectDoctorReportData,
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
@@ -42,8 +44,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
body?.locale,
request.headers.get("accept-language"),
);
const rawPracticeName = body?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
const data = await collectDoctorReportData(user.id, range);
if (typeof rawPracticeName === "string" && practiceName !== null) {
try {
await prisma.user.update({
where: { id: user.id },
data: { lastReportPracticeName: practiceName },
});
} catch {
// Best-effort; never let preference persistence break the report.
}
}
const data = await collectDoctorReportData(user.id, range, { practiceName });
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
@@ -56,6 +71,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
locale,
practiceNameProvided: practiceName !== null,
},
});
@@ -66,6 +82,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
report_end: range.end.toISOString(),
locale,
bytes: pdfBytes.byteLength,
practice_name_provided: practiceName !== null,
},
});
@@ -89,6 +106,7 @@ interface PdfRequestBody {
startDate?: unknown;
endDate?: unknown;
locale?: unknown;
practiceName?: unknown;
}
/**
+29 -4
View File
@@ -12,7 +12,9 @@ import { NextRequest } from "next/server";
import {
collectDoctorReportData,
normaliseDateRange,
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
/**
* Collect data for doctor report PDF generation (client-side).
@@ -20,9 +22,10 @@ import {
*
* Body shape (all fields optional — sensible defaults apply):
* {
* startDate?: string, // ISO timestamp, inclusive
* endDate?: string, // ISO timestamp, inclusive
* days?: number // legacy "last N days" fallback (1..365)
* startDate?: string, // ISO timestamp, inclusive
* endDate?: string, // ISO timestamp, inclusive
* days?: number, // legacy "last N days" fallback (1..365)
* practiceName?: string // free-text, persisted as user preference
* }
*
* Resolution: explicit range wins; otherwise `days` (default 90) is applied.
@@ -44,7 +47,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const range = normaliseDateRange(body);
const data = await collectDoctorReportData(user.id, range);
const rawPracticeName = (body as Record<string, unknown> | null)
?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// Persist the most-recent practice name as a user preference so the
// dialog can pre-fill it next time. We only write when the caller
// actually supplied a non-empty string — passing `null`/empty does NOT
// clear the stored preference (use the dedicated profile endpoint for
// that). Best-effort: a write failure here MUST NOT break the report.
if (typeof rawPracticeName === "string" && practiceName !== null) {
try {
await prisma.user.update({
where: { id: user.id },
data: { lastReportPracticeName: practiceName },
});
} catch {
// Non-fatal — preference persistence is a UX nicety, not a contract.
}
}
const data = await collectDoctorReportData(user.id, range, { practiceName });
await auditLog("doctor-report.generate", {
userId: user.id,
@@ -53,6 +76,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
days: range.days,
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
practiceNameProvided: practiceName !== null,
},
});
@@ -61,6 +85,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
report_days: range.days,
report_start: range.start.toISOString(),
report_end: range.end.toISOString(),
practice_name_provided: practiceName !== null,
},
});
@@ -3,8 +3,8 @@
/**
* Doctor-report export dialog (v1.4.15 phase B6).
*
* Wraps the trigger button so the user picks a reporting period before the
* PDF is generated.
* Wraps the trigger button so the user picks a reporting period and
* (optionally) a practice / clinic name before the PDF is generated.
*
* Defaults:
* - end = today
@@ -34,16 +34,20 @@ import { useTranslations } from "@/lib/i18n/context";
const ONE_DAY_MS = 86_400_000;
const MAX_RANGE_DAYS = 730;
const PRACTICE_NAME_MAX_LENGTH = 120;
export interface DoctorReportSubmitPayload {
startDate: string;
endDate: string;
practiceName: string | null;
}
interface DoctorReportDialogProps {
/** Controlled open state. */
open: boolean;
onOpenChange: (open: boolean) => void;
/** Last-used practice name from `/api/auth/me`; pre-fills the input. */
defaultPracticeName?: string | null;
/**
* Resolves with the user-confirmed payload. The dialog stays open and
* shows the spinner while the promise is pending; closes on resolve.
@@ -73,25 +77,28 @@ function defaultStartIso(): string {
export function DoctorReportDialog({
open,
onOpenChange,
defaultPracticeName,
onSubmit,
}: DoctorReportDialogProps) {
const { t } = useTranslations();
const [startDate, setStartDate] = useState<string>(defaultStartIso);
const [endDate, setEndDate] = useState<string>(todayIso);
const [practiceName, setPracticeName] = useState<string>("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset on open: re-anchor dates to defaults each time the dialog appears
// so a follow-up export doesn't reuse the previous custom range. Using
// the React-recommended "track-the-trigger" pattern (store the last
// observed `open` state and react during render) so the reset happens in
// the render phase rather than a setState-in-effect — that's the cleanest
// way to satisfy the strict `react-hooks/set-state-in-effect` rule.
// Reset on open: re-anchor dates + practice-name to defaults each time
// the dialog appears so a follow-up export doesn't reuse the previous
// custom range. Using the React-recommended "track-the-trigger" pattern
// (store the last observed `open` state and react during render) so the
// reset happens in the render phase rather than a setState-in-effect —
// satisfies the strict `react-hooks/set-state-in-effect` rule.
const [lastOpen, setLastOpen] = useState(false);
if (open && !lastOpen) {
setLastOpen(true);
setStartDate(defaultStartIso());
setEndDate(todayIso());
setPracticeName(defaultPracticeName ?? "");
setError(null);
setSubmitting(false);
} else if (!open && lastOpen) {
@@ -143,7 +150,15 @@ export function DoctorReportDialog({
// captures everything logged on each boundary day.
const startIso = new Date(`${startDate}T00:00:00`).toISOString();
const endIso = new Date(`${endDate}T23:59:59.999`).toISOString();
await onSubmit({ startDate: startIso, endDate: endIso });
const trimmedPractice = practiceName.trim();
await onSubmit({
startDate: startIso,
endDate: endIso,
practiceName:
trimmedPractice.length > 0
? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH)
: null,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
@@ -225,6 +240,21 @@ export function DoctorReportDialog({
</Button>
</div>
<div className="space-y-1.5">
<Label htmlFor="dr-practice">
{t("doctorReport.dialog.practiceLabel")}
</Label>
<Input
id="dr-practice"
type="text"
value={practiceName}
maxLength={PRACTICE_NAME_MAX_LENGTH}
placeholder={t("doctorReport.dialog.practicePlaceholder")}
onChange={(e) => setPracticeName(e.target.value)}
autoComplete="organization"
/>
</div>
{error && (
<p
role="alert"
+28 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react";
@@ -53,6 +53,28 @@ function ExportCard() {
const [exporting, setExporting] = useState(false);
const [generatingReport, setGeneratingReport] = useState(false);
const [reportDialogOpen, setReportDialogOpen] = useState(false);
const [lastPracticeName, setLastPracticeName] = useState<string | null>(null);
// Fetch the previously-stored practice name so the dialog can pre-fill it.
// `useEffect` is the right place for a one-shot side-effect on mount; the
// ESLint `set-state-in-effect` rule is fine here because we are not
// setting state on every render — we are reacting to a fetch.
useEffect(() => {
let cancelled = false;
fetch("/api/auth/me")
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled || !json) return;
const name = json?.data?.lastReportPracticeName;
if (typeof name === "string") setLastPracticeName(name);
})
.catch(() => {
// Best-effort; the dialog still works without the pre-fill.
});
return () => {
cancelled = true;
};
}, []);
async function handleExport(format: "csv" | "json") {
setExporting(true);
@@ -83,6 +105,7 @@ function ExportCard() {
body: JSON.stringify({
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
}),
});
if (!res.ok) return;
@@ -93,6 +116,9 @@ function ExportCard() {
const doc = generateDoctorReportPDF(json.data, { t, locale });
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
// Remember the last-used practice name so the next open of the dialog
// pre-fills it without a roundtrip to /api/auth/me.
if (payload.practiceName) setLastPracticeName(payload.practiceName);
setReportDialogOpen(false);
} finally {
setGeneratingReport(false);
@@ -142,6 +168,7 @@ function ExportCard() {
<DoctorReportDialog
open={reportDialogOpen}
onOpenChange={setReportDialogOpen}
defaultPracticeName={lastPracticeName}
onSubmit={handleDoctorReport}
/>
</div>
Binary file not shown.
@@ -37,6 +37,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
gender: "MALE",
heightCm: 182,
},
practiceName: null,
measurements: {
WEIGHT: [
{ value: 80, measuredAt: "2026-02-10T08:00:00.000Z" },
@@ -260,7 +261,43 @@ describe("doctor-report-pdf-core type-map coverage", () => {
expect(text).toContain("%");
});
// ── v1.4.15 phase B6 ── configurable date range on cover page.
// ── v1.4.15 phase B6 ── practice name on cover page.
it("renders the practice name on the cover when supplied (DE)", async () => {
const data = makeData({ practiceName: "Praxis Dr. Müller" });
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("Praxis Dr. Müller");
expect(text).toContain("Praxis:");
});
it("renders the practice name on the cover when supplied (EN)", async () => {
const data = makeData({ practiceName: "Family Practice Smith & Co." });
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("Family Practice Smith & Co.");
expect(text).toContain("Practice:");
});
it("omits the practice line when practiceName is null", async () => {
const data = makeData({ practiceName: null });
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const text = await extractText(bytes);
// Cover should NOT carry the "Praxis:" label when no name is set.
expect(text).not.toContain("Praxis:");
});
it("includes the explicit period start AND end on the cover page", async () => {
const data = makeData({
Binary file not shown.
+17
View File
@@ -168,6 +168,23 @@ export function buildDoctorReportPdfDocument(
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
// Practice / clinic name on the cover. Rendered prominently above the
// separator so the addressee is the first thing the doctor sees when
// skimming the printout. Skipped entirely when the user did not supply
// one — keeps the cover compact for self-archive use.
if (data.practiceName) {
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
margin,
y,
);
y += 6;
doc.setFont("helvetica", "normal");
}
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);
+17
View File
@@ -20,6 +20,8 @@ interface ReportData {
gender: string | null;
heightCm: number | null;
};
/** Free-text practice / clinic name printed on the cover. Optional. */
practiceName?: string | null;
stats: Record<
string,
{ avg: number; min: number; max: number; count: number; latest: number }
@@ -155,6 +157,21 @@ export function generateDoctorReportPDF(
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
// Practice / clinic name (optional). Rendered prominently above the
// separator so the addressee is visible at first glance.
if (data.practiceName) {
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
margin,
y,
);
y += 6;
doc.setFont("helvetica", "normal");
}
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);