diff --git a/src/app/c/[token]/__tests__/report-download.test.ts b/src/app/c/[token]/__tests__/report-download.test.ts index 3bfde19f6..b52bbf05c 100644 --- a/src/app/c/[token]/__tests__/report-download.test.ts +++ b/src/app/c/[token]/__tests__/report-download.test.ts @@ -43,6 +43,27 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() })); +// Wraps rather than replaces: the real renderer still runs, so the route is +// exercised end to end, and the options it was handed stay observable. +const pdfOptionsSeen: Array> = []; +vi.mock("@/lib/doctor-report-pdf-core", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + renderDoctorReportPdfBytes: ( + data: Parameters[0], + options: Parameters[1], + ) => { + pdfOptionsSeen.push(options as unknown as Record); + return actual.renderDoctorReportPdfBytes(data, options); + }, + }; +}); vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() })); vi.mock("@/lib/i18n/server-translator", async (importOriginal) => { const actual = @@ -60,6 +81,8 @@ import { loadShareViewData } from "@/lib/clinician-share/share-view-data"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { selectionFromLeaves } from "@/lib/report-selection/selection"; import type { DoctorReportData } from "@/lib/doctor-report-data"; @@ -127,6 +150,12 @@ beforeEach(() => { documents: [], documentOnly: false, } as never); + // West of UTC on purpose. Berlin is the display fallback, so a Berlin + // fixture cannot tell a resolved timezone apart from a forgotten one. + vi.mocked(resolveUserTimezone).mockResolvedValue("America/Los_Angeles"); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timeFormat: "H24", + } as never); }); describe("GET /c/{token}/fhir", () => { @@ -255,6 +284,38 @@ describe("GET /c/{token}/report.pdf", () => { expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-"); }); + it("prints the owner's day, not the display default", async () => { + // The defect: the route handed the renderer no timezone at all, so it fell + // back to the display default. A reading is dated by the day it was where + // the person lives, and a practice filing the PDF beside the page it came + // from must not find two different dates for one reading. + pdfOptionsSeen.length = 0; + const { GET } = await import("../report.pdf/route"); + await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params); + + expect(pdfOptionsSeen).toHaveLength(1); + expect( + pdfOptionsSeen[0].userTz, + "the renderer was handed no owner timezone — it will fall back to the display default", + ).toBe("America/Los_Angeles"); + expect( + pdfOptionsSeen[0].timeFormat, + "the owner's clock preference did not reach the renderer", + ).toBe("H24"); + }); + + it("falls back to AUTO when the owner row carries no clock preference", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + pdfOptionsSeen.length = 0; + const { GET } = await import("../report.pdf/route"); + const res = await GET( + new Request(`http://localhost/c/${TOKEN}/report.pdf`), + params, + ); + expect(res.status).toBe(200); + expect(pdfOptionsSeen[0].timeFormat).toBe("AUTO"); + }); + it("runs the same gate as the bundle beside it", async () => { vi.mocked(verifyUnlockValue).mockReturnValue(false); const { GET } = await import("../report.pdf/route"); diff --git a/src/app/c/[token]/report.pdf/route.ts b/src/app/c/[token]/report.pdf/route.ts index e2e4dd281..d6351723b 100644 --- a/src/app/c/[token]/report.pdf/route.ts +++ b/src/app/c/[token]/report.pdf/route.ts @@ -34,6 +34,14 @@ export const GET = apiHandler( const bytes = renderDoctorReportPdfBytes(resolved.report, { t, locale, + // The locale above is the reader's; these two are the OWNER's, and they + // have to be. A reading belongs to the day it was that day where the + // person was, so the date beside it cannot follow whoever opens the + // link. Without these the renderer fell back to the display default, + // which meant a practice west of Berlin could file a PDF dated one day + // off from the page it was downloaded from. + userTz: resolved.ownerTz, + timeFormat: resolved.ownerTimeFormat, insuranceNumber: null, includeCharts: true, }); diff --git a/src/lib/clinician-share/report-download.ts b/src/lib/clinician-share/report-download.ts index b341a1afe..a71c7f94a 100644 --- a/src/lib/clinician-share/report-download.ts +++ b/src/lib/clinician-share/report-download.ts @@ -35,6 +35,9 @@ import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { ReportSelection } from "@/lib/report-selection/selection"; +import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import type { TimeFormatPreference } from "@/generated/prisma/client"; /** * 20 per hour per link. A practice saves the record once, maybe twice; a @@ -53,7 +56,23 @@ function notFound() { } export type ReportDownloadResult = - | { ok: true; report: DoctorReportData; selection: ReportSelection } + | { + ok: true; + report: DoctorReportData; + selection: ReportSelection; + /** + * The OWNER's timezone and clock preference, not the reader's. + * + * A reading was recorded at a moment in the owner's life, and the date + * printed beside it has to be the date it was that day where they were. + * The page at `c/[token]` already resolves this; the downloads did not, + * so the same reading could carry one date on screen and another in the + * PDF a practice files. Resolved here rather than per route so the two + * download formats cannot drift apart the way the page and the PDF did. + */ + ownerTz: string; + ownerTimeFormat: TimeFormatPreference; + } | { ok: false; response: Response }; /** @@ -128,5 +147,19 @@ export async function resolveShareReportDownload( meta: { format, leafCount: view.selection.leaves.length }, }); - return { ok: true, report: view.report, selection: view.selection }; + const [ownerTz, ownerRow] = await Promise.all([ + resolveUserTimezone(context.ownerUserId), + prisma.user.findUnique({ + where: { id: context.ownerUserId }, + select: { timeFormat: true }, + }), + ]); + + return { + ok: true, + report: view.report, + selection: view.selection, + ownerTz, + ownerTimeFormat: ownerRow?.timeFormat ?? "AUTO", + }; }