From c6e782cdb8b052d8ad943ff6877fdbe01258f27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 22:28:25 +0200 Subject: [PATCH] fix(report): pass the in-app locale through to the pdf export The health-record export panel sent no locale, so the server resolved the report language from the browser's Accept-Language header. A German user on an English-default browser got an English doctor report. Send the active in-app locale from the panel, and on the server resolve most-specific-first: explicit selection, then the healthlog-locale cookie (what the user actually chose in the UI), then Accept-Language as the weakest fallback. --- .../health-record/__tests__/route.test.ts | 58 ++++++++++++++++++- src/app/api/export/health-record/route.ts | 14 ++++- .../settings/health-record-export-panel.tsx | 6 +- 3 files changed, 74 insertions(+), 4 deletions(-) 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 6a683a8cb..95b700be2 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -9,6 +9,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import { PDFParse } from "pdf-parse"; vi.mock("@/lib/db", () => ({ prisma: { @@ -38,14 +39,27 @@ const SESSION_OK = { user: { id: "user-1", email: "test@example.com", role: "USER" }, } as const; -function mkReq(body: unknown): NextRequest { +function mkReq( + body: unknown, + headers: Record = {}, +): NextRequest { return new NextRequest("http://localhost/api/export/health-record", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), }); } +async function pdfText(res: Response): Promise { + const bytes = new Uint8Array(await res.arrayBuffer()); + const parser = new PDFParse({ data: bytes }); + try { + return (await parser.getText()).text; + } finally { + await parser.destroy(); + } +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -137,6 +151,46 @@ describe("POST /api/export/health-record — outputs", () => { expect(buf.subarray(0, 2).toString("latin1")).toBe("PK"); }); + it("renders the PDF in the explicit selection locale", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq({ format: "pdf", locale: "de", practiceName: "Sample Practice" }), + ); + const text = await pdfText(res); + // German cover label. + expect(text).toContain("Praxis:"); + }); + + it("falls back to the healthlog-locale cookie when no locale is sent", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq( + { format: "pdf", practiceName: "Sample Practice" }, + { + // Browser default English, but the in-app cookie says German — the + // cookie must win over Accept-Language. + "accept-language": "en-US,en;q=0.9", + cookie: "healthlog-locale=de", + }, + ), + ); + const text = await pdfText(res); + expect(text).toContain("Praxis:"); + expect(text).not.toContain("Practice:"); + }); + + it("uses Accept-Language only when neither selection nor cookie is present", async () => { + const { POST } = await import("../route"); + const res = await POST( + mkReq( + { format: "pdf", practiceName: "Sample Practice" }, + { "accept-language": "en-US,en;q=0.9" }, + ), + ); + const text = await pdfText(res); + expect(text).toContain("Practice:"); + }); + it("scopes the aggregator measurement read to the session user", async () => { const { POST } = await import("../route"); await POST(mkReq({ format: "fhir", userId: "user-2" } as never)); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index c4765d2b2..842845a11 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -46,7 +46,7 @@ import { } from "@/lib/validations/health-record-export"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; -import { type Locale } from "@/lib/i18n/config"; +import { locales, type Locale } from "@/lib/i18n/config"; import { resolveUserTimezone } from "@/lib/tz/resolver"; export const POST = apiHandler(async (request: NextRequest) => { @@ -72,8 +72,20 @@ export const POST = apiHandler(async (request: NextRequest) => { const sections = toDoctorReportPrefs(selection.sections); const includeCharts = selection.includeCharts ?? true; + // Locale resolution, most-specific first: the explicit selection wins, then + // the in-app `healthlog-locale` cookie (what the user actually chose in the + // UI), and only then the browser's Accept-Language header. The header is the + // weakest signal — a German user on an English-default browser would + // otherwise get an English report. + const cookieValue = request.cookies.get("healthlog-locale")?.value; + const cookieLocale = (locales as readonly string[]).includes( + cookieValue ?? "", + ) + ? (cookieValue as Locale) + : null; const locale: Locale = selection.locale ?? + cookieLocale ?? parseLocaleFromAcceptLanguage( request.headers.get("accept-language") ?? "", ); diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 78be0dcfd..67253b3a2 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -97,7 +97,7 @@ function buildSelectionSections(s: SectionState) { } export function HealthRecordExportPanel() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const [format, setFormat] = useState("pdf"); const [days, setDays] = useState(90); const [practiceName, setPracticeName] = useState(""); @@ -140,6 +140,10 @@ export function HealthRecordExportPanel() { credentials: "include", body: JSON.stringify({ format, + // Carry the active in-app locale so the generated artefact matches + // the UI language instead of falling back to the browser's + // Accept-Language header on the server. + locale, range: { days }, practiceName: practiceName.trim() || undefined, includeCharts,