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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 22:47:44 +02:00
1 parent 24ffca7220
commit c6e782cdb8
3 files changed
+74 -4

No files matched your search

@@ -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<string, string> = {},
): 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<string> {
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));
+13 -1
View File
@@ -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") ?? "",
);
@@ -97,7 +97,7 @@ function buildSelectionSections(s: SectionState) {
}
export function HealthRecordExportPanel() {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(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,