From 87877b285fcce54e371d3f165b5600cf6230063e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 22:37:10 +0200 Subject: [PATCH] fix(share): date a shared PDF by the owner's day, not the display default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page at c/[token] resolved the owner's timezone; the PDF download beside it resolved nothing and fell through to the display default. Same link, same reading, two dates — and the one a practice files is the one that was wrong. Resolved in the shared download gate rather than in the route, so the PDF and the FHIR bundle cannot drift apart the way the page and the PDF did. The owner's clock preference travels with it for the same reason. The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin fixture cannot tell a resolved timezone from a forgotten one. --- .../[token]/__tests__/report-download.test.ts | 61 +++++++++++++++++++ src/app/c/[token]/report.pdf/route.ts | 8 +++ src/lib/clinician-share/report-download.ts | 37 ++++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) 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", + }; }