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: {}, };