From a3f1d780e7971686d2e3050510c4645a26fa5785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 15:00:27 +0200 Subject: [PATCH] feat(export): remember the practice name between doctor reports The doctor-report contract has always described the practice / clinic name as a stored preference, and `/api/auth/me` has returned `User.lastReportPracticeName` since it shipped, but nothing ever wrote the column. Every report asked for the clinic name again. The export route now persists the sanitised name after the requested artefact has actually been produced, so a generation that throws cannot overwrite the remembered value with a half-typed one. Clearing the field persists null, which keeps the cover line cleared next time. The export panel seeds its input from the auth payload during render, gated per user id, so a late resolve never clobbers a field the user is already typing into, and a finished export invalidates the cached payload so a remount reads the new value. --- .../health-record/__tests__/route.test.ts | 73 ++++++++++++++ src/app/api/export/health-record/route.ts | 26 +++++ .../__tests__/disable-coach-card.test.tsx | 1 + ...ealth-record-export-practice-name.test.tsx | 97 +++++++++++++++++++ .../__tests__/modules-section.test.tsx | 1 + .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + .../settings/health-record-export-panel.tsx | 21 ++++ src/hooks/use-auth.ts | 7 ++ .../__tests__/coach-user-disable.test.tsx | 1 + 10 files changed, 229 insertions(+) create mode 100644 src/components/settings/__tests__/health-record-export-practice-name.test.tsx diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 08ad8e15b..2edf0ab3c 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -249,3 +249,76 @@ describe("POST /api/export/health-record — outputs", () => { ); }); }); + +/** + * The practice name is a remembered preference (`User.lastReportPracticeName`), + * so a monthly report does not ask for the clinic name again every time. The + * write happens only once the artefact exists — a generation that throws must + * not overwrite the remembered value with a half-typed one. + * + * Mutation check: drop the `rememberPracticeName` call in the route and the + * first two cases go red; move it above the generation and the + * failed-generation case goes red. + */ +describe("POST /api/export/health-record — remembered practice name", () => { + it("persists the sanitised practice name after a PDF export", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "pdf", practiceName: " Sample Practice " }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: "Sample Practice" }, + }); + }); + + it("persists the practice name after a FHIR export", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "fhir", practiceName: "Sample Practice" }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: "Sample Practice" }, + }); + }); + + it("persists null when the user cleared the practice name", async () => { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "pdf" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastReportPracticeName: null }, + }); + }); + + it("writes nothing when the selection is rejected", async () => { + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "xml", practiceName: "Sample" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("writes nothing when the generation fails", async () => { + vi.mocked(prisma.measurement.findMany).mockRejectedValue( + new Error("read failed"), + ); + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "pdf", practiceName: "Sample Practice" }), + ); + expect(res.status).toBe(500); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("narrows the update to the session user, never the body", async () => { + const { POST } = await import("../route"); + await POST(mkReq({ format: "fhir", practiceName: "Sample Practice" })); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-1" } }), + ); + }); +}); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 83253d1ff..adde3d743 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -54,6 +54,28 @@ import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; import { locales, type Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +/** + * Remember the practice / clinic name for the next report, so the user does + * not retype it every time (`User.lastReportPracticeName`, the preference the + * doctor-report contract has always promised). + * + * Called only once the requested artefact has actually been produced: a + * generation that throws must not overwrite the remembered name with a + * half-typed one. `null` is a legitimate value — the user cleared the line and + * the next cover should stay cleared. The value is the already-sanitised one + * (`sanitisePracticeName`, 120-char cap), and the update names its single + * field explicitly rather than spreading the parsed selection. + */ +async function rememberPracticeName( + userId: string, + practiceName: string | null, +) { + await prisma.user.update({ + where: { id: userId }, + data: { lastReportPracticeName: practiceName }, + }); +} + export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "export.health-record.build" } }); @@ -189,6 +211,7 @@ export const POST = apiHandler(async (request: NextRequest) => { fhirRecords, ); const json = JSON.stringify(bundle); + await rememberPracticeName(user.id, practiceName); annotate({ meta: { format: "fhir", bytes: json.length, days: range.days }, }); @@ -212,6 +235,9 @@ export const POST = apiHandler(async (request: NextRequest) => { includeCharts, aiSummary, }); + // Both remaining formats (`pdf`, `package`) carry the rendered PDF, so the + // artefact exists from here on. + await rememberPracticeName(user.id, practiceName); if (selection.format === "pdf") { annotate({ diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 945933485..b6b326e09 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -74,6 +74,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/health-record-export-practice-name.test.tsx b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx new file mode 100644 index 000000000..24c5c298e --- /dev/null +++ b/src/components/settings/__tests__/health-record-export-practice-name.test.tsx @@ -0,0 +1,97 @@ +/** + * Settings → Gesundheitsakte: the practice name is remembered between reports. + * + * `User.lastReportPracticeName` is persisted by the export route after a + * successful generation and rides the `/api/auth/me` payload. This suite pins + * the read half: the panel opens pre-filled with the last-used name instead of + * asking for the clinic name again on every report. + * + * SSR-only, per the project convention for this surface (node environment, + * no `@testing-library/react`). The seed runs during render — a `useState` + * initializer would read `undefined` while `useAuth` is still resolving and + * stick at empty — so the static markup already carries the seeded value. + * + * Mutation check: revert the seeding block in the panel and the first case + * goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { AuthUser } from "@/hooks/use-auth"; + +const authSpy = vi.fn<() => { user: AuthUser | null }>(); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { HealthRecordExportPanel } from "../health-record-export-panel"; + +function buildUser(lastReportPracticeName: string | null): AuthUser { + return { + id: "user-1", + username: "user", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + onboardingTourProgress: null, + avatarUrl: null, + glucoseUnit: null, + unitPreference: "metric", + timeFormat: "AUTO", + dateFormat: "AUTO", + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + lastReportPracticeName, + cycleTrackingEnabled: false, + modules: {}, + } as AuthUser; +} + +function renderPanel(user: AuthUser | null): string { + authSpy.mockReturnValue({ user }); + return renderToStaticMarkup( + + + , + ); +} + +function practiceInputValue(html: string): string | null { + const input = html.match(/]*id="hr-practice"[^>]*>/)?.[0]; + if (!input) return null; + return input.match(/value="([^"]*)"/)?.[1] ?? ""; +} + +describe(" — remembered practice name", () => { + it("pre-fills the input with the last-used practice name", () => { + const html = renderPanel(buildUser("Sample Practice")); + expect(practiceInputValue(html)).toBe("Sample Practice"); + }); + + it("leaves the input empty when no name was ever remembered", () => { + const html = renderPanel(buildUser(null)); + expect(practiceInputValue(html)).toBe(""); + }); + + it("leaves the input empty while the auth payload is still resolving", () => { + const html = renderPanel(null); + expect(practiceInputValue(html)).toBe(""); + }); +}); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 88c93e4d1..a77475675 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -107,6 +107,7 @@ function buildUser( insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules, moduleAvailability: {}, diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index cea538c04..27c89a7c8 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -52,6 +52,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index e944c2ddc..9fc1945dd 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -55,6 +55,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, }; diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 385efb790..70bc7d956 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -22,6 +22,7 @@ import Link from "next/link"; import { useId, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Download, @@ -41,6 +42,7 @@ import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; type ExportFormat = "pdf" | "fhir" | "package"; @@ -126,9 +128,13 @@ function buildSelectionSections(s: SectionState) { export function HealthRecordExportPanel() { const { t, locale } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); const [format, setFormat] = useState("pdf"); const [days, setDays] = useState(90); const [practiceName, setPracticeName] = useState(""); + const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState< + string | null + >(null); const [includeCharts, setIncludeCharts] = useState(true); const [includeAiSummary, setIncludeAiSummary] = useState(false); const [sections, setSections] = useState(DEFAULT_SECTIONS); @@ -139,6 +145,17 @@ export function HealthRecordExportPanel() { const [includedDataOpen, setIncludedDataOpen] = useState(false); const includedDataPanelId = useId(); + // Seed the practice name from the last export once the auth payload lands. + // `useAuth` resolves asynchronously, so a `useState` initializer would read + // `undefined` and stick at empty. Store-the-id-alongside-the-state (the same + // pattern the account form uses) syncs during render instead of in an + // effect, and the per-user gate means a late re-resolve never clobbers a + // field the user is already typing into. + if (user && user.id !== practiceNameSeededUserId) { + setPracticeNameSeededUserId(user.id); + setPracticeName(user.lastReportPracticeName ?? ""); + } + const isPdfLike = format === "pdf" || format === "package"; const isFhirLike = format === "fhir" || format === "package"; @@ -199,6 +216,10 @@ export function HealthRecordExportPanel() { a.click(); a.remove(); URL.revokeObjectURL(url); + // The route just persisted the practice name, so the cached `/me` + // payload is stale. Refresh it, or a remount of this panel would seed + // from the previous value. + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 537ed1dac..af8208231 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -116,6 +116,13 @@ export interface AuthUser { */ insurerIkNumber: string | null; insuranceNumber: string | null; + /** + * Practice / clinic name from the last health-record export, so the export + * panel opens pre-filled instead of asking for it again every month. The + * export route persists it after a successful generation; `null` means the + * user has never set one, or deliberately cleared the line. + */ + lastReportPracticeName: string | null; /** * v1.15.0 — cycle-tracking feature gate, resolved server-side from gender * + the per-user opt-in. The cycle nav entry + page hide when false; every diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index c85a9a275..4dc6553aa 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerName: null, insurerIkNumber: null, insuranceNumber: null, + lastReportPracticeName: null, cycleTrackingEnabled: false, modules: {}, };