diff --git a/src/app/api/documents/inbound/[id]/confirm/route.ts b/src/app/api/documents/inbound/[id]/confirm/route.ts index 564af8e62..919398228 100644 --- a/src/app/api/documents/inbound/[id]/confirm/route.ts +++ b/src/app/api/documents/inbound/[id]/confirm/route.ts @@ -43,7 +43,8 @@ async function confirmDocument( const document = await prisma.inboundDocument.findFirst({ where: { id, userId: user.id, deletedAt: null }, - select: { id: true }, + // The two dates date a value that states none of its own. + select: { id: true, reportDate: true, documentDate: true }, }); if (!document) { return apiError("Document not found", 404, { @@ -94,10 +95,15 @@ async function confirmDocument( try { // v1.43 — an OBSERVATION may name the marker the person chose // on the review screen; the label is then remembered as its alias. - const ref = await commitApprovedFact(user.id, fact, { - biomarkerId: decision.biomarkerId, - rememberAlias: decision.rememberAlias, - }); + const ref = await commitApprovedFact( + user.id, + fact, + { + biomarkerId: decision.biomarkerId, + rememberAlias: decision.rememberAlias, + }, + document, + ); await prisma.extractedFact.update({ where: { id: fact.id }, data: { diff --git a/src/lib/documents/__tests__/commit.test.ts b/src/lib/documents/__tests__/commit.test.ts index 591ebae47..81deb22dd 100644 --- a/src/lib/documents/__tests__/commit.test.ts +++ b/src/lib/documents/__tests__/commit.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { labResult: { create: vi.fn() }, + illnessEpisode: { create: vi.fn() }, }, })); @@ -27,8 +28,16 @@ import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit"; 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 type { + ExtractedFact, + IllnessEpisode, + LabResult, +} from "@/generated/prisma/client"; import { userDayKey } from "@/lib/tz/format"; +import type { + ConditionFactData, + ObservationFactData, +} from "@/lib/validations/inbound-documents"; function observationFact(): ExtractedFact { return { @@ -213,3 +222,181 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => { ).resolves.toEqual({ recordType: "labResult", recordId: "lr-2" }); }); }); + +describe("commitApprovedFact — OBSERVATION date when the value states none", () => { + // A lab report that prints the collection date once in its header carries + // no date beside each value. Mutation check: drop the document day from + // `takenAt` → the report-date, filing-date and impossible-date cases file + // the reading under today. + function undated(effectiveDate: string | null = null) { + const data: ObservationFactData = { + label: "Ferritin", + code: null, + codeSystem: null, + value: 80, + valueText: null, + unit: "ng/mL", + referenceLow: null, + referenceHigh: null, + effectiveDate, + }; + vi.mocked(decryptFactData).mockReturnValue(data); + vi.mocked(resolveOrMintBiomarker).mockResolvedValue({ + id: "bm-3", + name: "Ferritin", + unit: "ng/mL", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: null, + }); + vi.mocked(prisma.labResult.create).mockResolvedValue({ + id: "lr-3", + } as LabResult); + } + + /** The dates as the confirm route reads them off the stored document. */ + function dates(reportDate: string | null, documentDate: string | null) { + return { + // An older report date sits at UTC midnight, newer ones at noon UTC. + reportDate: reportDate ? new Date(`${reportDate}T00:00:00.000Z`) : null, + documentDate: documentDate + ? new Date(`${documentDate}T12:00:00.000Z`) + : null, + }; + } + + function storedTakenAt(): Date { + return vi.mocked(prisma.labResult.create).mock.calls[0]![0].data + .takenAt as Date; + } + + it("keeps the value's own date", async () => { + undated("2021-03-04"); + + await commitApprovedFact( + "user-1", + observationFact(), + {}, + dates("2021-03-01", "2026-10-20"), + ); + + expect(storedTakenAt().toISOString()).toBe("2021-03-04T12:00:00.000Z"); + }); + + it("takes the report date the document states over its filing date", async () => { + undated(); + + await commitApprovedFact( + "user-1", + observationFact(), + {}, + dates("2021-03-01", "2026-10-20"), + ); + + expect(storedTakenAt().toISOString()).toBe("2021-03-01T12:00:00.000Z"); + }); + + it("takes the filing date when the document states no report date", async () => { + undated(); + + await commitApprovedFact( + "user-1", + observationFact(), + {}, + dates(null, "2021-02-15"), + ); + + expect(storedTakenAt().toISOString()).toBe("2021-02-15T12:00:00.000Z"); + }); + + it.each([ + ["not a date", "March 2021"], + ["a day the month does not have", "2021-02-30"], + ["a month that does not exist", "2021-13-01"], + ])( + "reads a value whose own date is %s as undated", + async (_label, effectiveDate) => { + undated(effectiveDate); + + await commitApprovedFact( + "user-1", + observationFact(), + {}, + dates("2021-03-01", null), + ); + + // Not 2 March for 30 February, and no Invalid Date for month 13. + expect(storedTakenAt().toISOString()).toBe("2021-03-01T12:00:00.000Z"); + }, + ); + + it("files the reading under now when neither the value nor the document has a date", async () => { + undated(); + + const before = Date.now(); + await commitApprovedFact( + "user-1", + observationFact(), + {}, + dates(null, null), + ); + const after = Date.now(); + + const takenAt = storedTakenAt().getTime(); + expect(takenAt).toBeGreaterThanOrEqual(before); + expect(takenAt).toBeLessThanOrEqual(after); + }); +}); + +describe("commitApprovedFact — CONDITION onset", () => { + // The onset goes through the same stated-day check as a lab value's date. + function condition(onsetDate: string | null) { + const data: ConditionFactData = { + label: "Iron deficiency", + code: null, + codeSystem: null, + clinicalStatus: null, + verificationStatus: null, + onsetDate, + }; + vi.mocked(decryptFactData).mockReturnValue(data); + vi.mocked(prisma.illnessEpisode.create).mockResolvedValue({ + id: "ep-1", + } as IllnessEpisode); + } + + function conditionFact(): ExtractedFact { + return { + id: "fact-2", + factType: "CONDITION", + dataEncrypted: new Uint8Array(), + } as unknown as ExtractedFact; + } + + function storedOnset(): Date { + return vi.mocked(prisma.illnessEpisode.create).mock.calls[0]![0].data + .onsetAt as Date; + } + + it("keeps a stated onset day", async () => { + condition("2021-03-04"); + + await commitApprovedFact("user-1", conditionFact()); + + expect(storedOnset().toISOString()).toBe("2021-03-04T12:00:00.000Z"); + }); + + it("files an onset that is not a real day under now, not an invalid date", async () => { + // 2021-13-01 used to become an invalid date that failed the whole confirm. + condition("2021-13-01"); + + const before = Date.now(); + await commitApprovedFact("user-1", conditionFact()); + const after = Date.now(); + + const onset = storedOnset().getTime(); + expect(onset).toBeGreaterThanOrEqual(before); + expect(onset).toBeLessThanOrEqual(after); + }); +}); diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index db39f3d31..17bae2712 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -44,7 +44,11 @@ import type { MedicationStatementFactData, ObservationFactData, } from "@/lib/validations/inbound-documents"; -import { dateOnlyAtNoonUtc, isDateOnlyKey } from "@/lib/tz/date-only"; +import { + dateOnlyAtNoonUtc, + dateOnlyKey, + isCalendarDateKey, +} from "@/lib/tz/date-only"; /** A per-fact commit failure the caller maps to a per-fact 422 entry. */ export class FactCommitError extends Error { @@ -61,14 +65,41 @@ export interface CommittedRecordRef { recordId: string; } +/** A stated day, or null when it is not a real calendar date. */ +function statedDay(date: string | null): string | null { + return date && isCalendarDateKey(date) ? date : null; +} + /** * 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 + * UTC), else the fallback day, else now. UTC midnight read back as the * previous day anywhere west of UTC. */ -function statedDateOrNow(date: string | null): Date { - if (date && isDateOnlyKey(date)) return dateOnlyAtNoonUtc(date); - return new Date(); +function statedDateOrNow( + date: string | null, + fallback: string | null = null, +): Date { + const day = statedDay(date) ?? fallback; + return day ? dateOnlyAtNoonUtc(day) : new Date(); +} + +/** The two dates a stored document carries, as the confirm route reads them. */ +export interface FactDocumentDates { + reportDate: Date | null; + documentDate: Date | null; +} + +/** + * The day a value from this document took place when it states none of its + * own: the report date the model read off the document, else the document's + * filing date, the same order the day view and the timeline place a document + * by. A lab report that prints the collection date once in its header carries + * no date beside each value, and every reading used to be filed under the day + * it was confirmed. + */ +function documentDay(dates: FactDocumentDates | null): string | null { + const day = dates?.reportDate ?? dates?.documentDate ?? null; + return day ? dateOnlyKey(day) : null; } /** @@ -84,6 +115,7 @@ async function commitObservation( userId: string, data: ObservationFactData, choice: ObservationChoice = {}, + fallbackDay: string | null = null, ): Promise { const isQualitative = typeof data.value !== "number"; // A numeric reading needs a unit so the catalog has something to mint with — @@ -228,7 +260,7 @@ async function commitObservation( sourceReferenceLow: storedSourceLow, sourceReferenceHigh: storedSourceHigh, sourceReferenceText: isQualitative ? null : sourceText, - takenAt: statedDateOrNow(data.effectiveDate), + takenAt: statedDateOrNow(data.effectiveDate, fallbackDay), source: "DOCUMENT", noteEncrypted: null, }, @@ -317,12 +349,18 @@ export async function commitApprovedFact( userId: string, fact: ExtractedFact, choice: ObservationChoice = {}, + documentDates: FactDocumentDates | null = null, ): Promise { // Decrypt the staged payload at confirm time to write it into the normal // structured store. Fail-closed: a bad key id throws and aborts the commit. const data = decryptFactData(fact.dataEncrypted); if (fact.factType === "OBSERVATION") { - return commitObservation(userId, data as ObservationFactData, choice); + return commitObservation( + userId, + data as ObservationFactData, + choice, + documentDay(documentDates), + ); } if (fact.factType === "CONDITION") { return commitCondition(userId, data as ConditionFactData);