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); +}