Merge pull request #1293 from haritowa/documents-fact-date-from-document

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 08:37:56 +01:00
commit cb80296a2a
4 files changed
+466 -13

No files matched your search

@@ -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: {
+188 -1
View File
@@ -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);
});
});
+45 -7
View File
@@ -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<CommittedRecordRef> {
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<CommittedRecordRef> {
// 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);