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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:33:50 +02:00
1 parent 4ac05364fe
commit 838629276c
10 files changed
+111 -11

No files matched your search

@@ -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);
@@ -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({
@@ -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) => ({
+2 -1
View File
@@ -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;
}
@@ -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 }[];
};
@@ -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");
});
});
+2 -1
View File
@@ -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,
},
});
+7 -4
View File
@@ -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();
}
+16 -4
View File
@@ -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,
+36
View File
@@ -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);
}