mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge pull request #1293 from haritowa/documents-fact-date-from-document
This commit is contained in:
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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user