From 838629276cd34f88632f75063b59ac3db96b59e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:33:50 +0200 Subject: [PATCH] fix(documents): store stated dates at noon UTC A report's stated date, a document's filing date and the date a committed lab reading or condition carries were written as UTC midnight, which is the previous evening west of UTC: a reading dated the 1st showed on the 31st. Date-only values are now stored at noon UTC through one helper in the time zone module, the convention the lab scan and the cycle engine already use. Rows written at midnight still read back to the same date. A document uploaded without a date files under the uploader's own day rather than the UTC one. --- .../inbound/[id]/__tests__/route.test.ts | 23 ++++++++++++ .../[id]/extract/__tests__/route.test.ts | 8 +++++ .../documents/inbound/[id]/extract/route.ts | 3 +- src/app/api/documents/inbound/[id]/route.ts | 3 +- .../__tests__/auto-stage-labs.test.ts | 7 ++++ src/lib/documents/__tests__/commit.test.ts | 8 +++++ src/lib/documents/auto-stage-labs.ts | 3 +- src/lib/documents/commit.ts | 11 +++--- src/lib/documents/ingest.ts | 20 ++++++++--- src/lib/tz/date-only.ts | 36 +++++++++++++++++++ 10 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/lib/tz/date-only.ts diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 3b1f74874..b82bd9d65 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -232,6 +232,29 @@ describe("PATCH /api/documents/inbound/[id]", () => { expect("documentDate" in arg.data).toBe(false); }); + it("stores an edited filing date at noon UTC so it reads back on that day everywhere", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.inboundDocument.update).mockResolvedValue( + docRow() as never, + ); + vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue( + docRow() as never, + ); + + const res = await PATCH( + mkReq("doc-1", { documentDate: "2026-05-03" }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0]; + // UTC midnight is the previous evening west of UTC. + expect((arg.data.documentDate as Date).toISOString()).toBe( + "2026-05-03T12:00:00.000Z", + ); + }); + it("422s on an empty update", async () => { const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never); expect(res.status).toBe(422); diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index 267c366bd..ccb39338b 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -288,6 +288,14 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => { data: expect.objectContaining({ status: "EXTRACTED" }), }), ); + // The stated report date is a date-only value: stored at noon UTC, not + // at UTC midnight (the previous evening west of UTC). + const staged = tx.inboundDocument.update.mock.calls[0]![0] as { + data: { reportDate: Date }; + }; + expect(staged.data.reportDate.toISOString()).toBe( + "2026-08-01T12:00:00.000Z", + ); expect(auditLog).toHaveBeenCalledWith( "documents.inbound.extract", expect.objectContaining({ diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index f6bd96a3b..930d8c895 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -76,6 +76,7 @@ import { inboundStoredExtractSchema, inboundTextExtractSchema, } from "@/lib/validations/inbound-documents"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; export const dynamic = "force-dynamic"; @@ -111,7 +112,7 @@ async function stageExtraction( status: "EXTRACTED", providerType: result.providerType, reportDate: result.reportDate - ? new Date(`${result.reportDate}T00:00:00.000Z`) + ? dateOnlyAtNoonUtc(result.reportDate) : null, facts: { create: result.facts.map((f) => ({ diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 8876d279c..b6f512a27 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -41,6 +41,7 @@ import { toContentIndexSource, } from "@/lib/validations/inbound-documents"; import type { Prisma } from "@/generated/prisma/client"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; type RouteParams = { params: Promise<{ id: string }> }; @@ -232,7 +233,7 @@ export const PATCH = apiHandler( if (parsed.data.kind !== undefined) data.kind = parsed.data.kind; if (parsed.data.documentDate !== undefined) { data.documentDate = parsed.data.documentDate - ? new Date(`${parsed.data.documentDate}T00:00:00.000Z`) + ? dateOnlyAtNoonUtc(parsed.data.documentDate) : null; } diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts index 8f522f927..ebcde0792 100644 --- a/src/lib/documents/__tests__/auto-stage-labs.test.ts +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -159,6 +159,13 @@ describe("maybeAutoStageLabFacts", () => { data: expect.objectContaining({ status: "EXTRACTED" }), }), ); + // The stated report date is stored at noon UTC, not UTC midnight. + const moved = updateMany.mock.calls[0]![0] as { + data: { reportDate: Date }; + }; + expect(moved.data.reportDate.toISOString()).toBe( + "2026-07-10T12:00:00.000Z", + ); const created = createMany.mock.calls[0]![0] as { data: { status: string }[]; }; diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 1e858b531..4a54f40d9 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; import { decryptFactData } from "@/lib/documents/store"; import type { ExtractedFact } from "@/generated/prisma/client"; +import { userDayKey } from "@/lib/tz/format"; function observationFact(): ExtractedFact { return { @@ -94,5 +95,12 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { const ref = await commitApprovedFact("user-1", observationFact()); expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" }); expect(prisma.labResult.create).toHaveBeenCalledOnce(); + // The stated date is stored at noon UTC: it reads back on the stated day + // as a UTC label and formatted in a zone west of UTC alike. UTC midnight + // showed the reading a day early there. + const takenAt = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data + .takenAt as Date; + expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z"); + expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01"); }); }); diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index c66b24b13..ef9133ee8 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -48,6 +48,7 @@ import { localisedValues } from "@/lib/i18n/shared-resolve"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; /** * Lab-report signals used to decide whether a transcribed document is worth an @@ -215,7 +216,7 @@ async function stageFactsIfStored( status: "EXTRACTED", providerType: result.providerType, reportDate: result.reportDate - ? new Date(`${result.reportDate}T00:00:00.000Z`) + ? dateOnlyAtNoonUtc(result.reportDate) : null, }, }); diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index e22f3d349..1bc271dfd 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -39,6 +39,7 @@ import type { MedicationStatementFactData, ObservationFactData, } from "@/lib/validations/inbound-documents"; +import { dateOnlyAtNoonUtc, isDateOnlyKey } from "@/lib/tz/date-only"; /** A per-fact commit failure the caller maps to a per-fact 422 entry. */ export class FactCommitError extends Error { @@ -55,11 +56,13 @@ export interface CommittedRecordRef { recordId: string; } -/** Parse a stated YYYY-MM-DD into a UTC instant, or fall back to now. */ +/** + * A stated YYYY-MM-DD as the instant a date-only value is stored at (noon + * UTC), or now when the document states none. UTC midnight read back as the + * previous day anywhere west of UTC. + */ function statedDateOrNow(date: string | null): Date { - if (date && /^\d{4}-\d{2}-\d{2}$/u.test(date)) { - return new Date(`${date}T00:00:00.000Z`); - } + if (date && isDateOnlyKey(date)) return dateOnlyAtNoonUtc(date); return new Date(); } diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts index 0932ba813..ece28bae6 100644 --- a/src/lib/documents/ingest.ts +++ b/src/lib/documents/ingest.ts @@ -58,6 +58,9 @@ import type { DocumentSourceSystemValue, InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; /** * The person's own uploads per hour, from the web or the phone, and the @@ -74,7 +77,12 @@ export function personalUploadBucket(userId: string): string { return `documents-upload:${userId}`; } -/** Parse a YYYY-MM-DD value into a UTC midnight Date. */ +/** + * A YYYY-MM-DD as UTC midnight: the lower bound of that date for a range + * filter over stored date-only values (which sit at noon UTC, or at UTC + * midnight for rows written before the noon anchor). Not a storage anchor; + * a stored date goes through `dateOnlyAtNoonUtc`. + */ export function isoDateToUtc(value: string): Date { return new Date(`${value}T00:00:00.000Z`); } @@ -297,9 +305,13 @@ export async function ingestDocument( contentCodec: codec, contentSha256, status: "STORED", - documentDate: input.documentDate - ? isoDateToUtc(input.documentDate) - : new Date(), + // The upload day when none is stated is the uploader's own day, + // not the UTC one (just after midnight east of UTC that was + // yesterday). + documentDate: dateOnlyAtNoonUtc( + input.documentDate ?? + userDayKey(new Date(), await resolveUserTimezone(input.userId)), + ), sourceSystem, sourceId, sourceInstance, diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts new file mode 100644 index 000000000..df98246d9 --- /dev/null +++ b/src/lib/tz/date-only.ts @@ -0,0 +1,36 @@ +/** + * Date-only values: a calendar date (`YYYY-MM-DD`) with no time of day — a + * report's stated date, a document's filing date, a backup's label for a + * `@db.Date` column. + * + * Such a value is stored in a timestamp column as noon UTC. Noon keeps the + * date on the same calendar day whether it is read back as a UTC label + * ({@link dateOnlyKey}) or formatted in any zone from UTC-11 to UTC+11. The + * older anchor, UTC midnight, sits on the previous evening everywhere west + * of UTC, which is how a stated report date came to show a day early. + * + * Rows written at UTC midnight before the switch still read back to the same + * key through {@link dateOnlyKey}, so the two anchors coexist without a + * migration. + */ + +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u; + +/** True for a strict `YYYY-MM-DD` string. */ +export function isDateOnlyKey(value: string): boolean { + return DATE_ONLY_RE.test(value); +} + +/** The instant a date-only value is stored as: 12:00 UTC on that date. */ +export function dateOnlyAtNoonUtc(key: string): Date { + return new Date(`${key}T12:00:00.000Z`); +} + +/** + * The calendar date of a stored date-only value (or of a `@db.Date` column, + * which Prisma hands back as UTC midnight). Only for values that ARE dates: + * the day an instant fell on for a person is `userDayKey(instant, tz)`. + */ +export function dateOnlyKey(value: Date): string { + return value.toISOString().slice(0, 10); +}