Files
HealthLog/src/lib/documents/commit.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

332 lines
13 KiB
TypeScript

/**
* v1.25 — commit an APPROVED staged fact into a structured store.
*
* The only write path out of the staging area. Each approved fact is routed to
* its existing structured store through the same field-by-field create the
* manual / OCR paths use — no mass assignment, owner-scoped:
* - OBSERVATION → `LabResult` (resolve-or-mint the biomarker)
* - CONDITION → `IllnessEpisode` (condition journal)
* - MEDICATION_STATEMENT → `Medication` (as-needed record, no reminders)
*
* The app reproduces what the document stated; it never interprets. A Condition
* is stored with `type = OTHER` (mapping a free-text diagnosis to a clinical
* category would BE interpretation) and the stated status/code is transcribed
* verbatim into the encrypted note. A MedicationStatement is recorded as an
* as-needed medication with notifications off — a record of what the patient
* takes, never a prescription action. An Observation never carries a
* range-flag; the reference bounds ride along only as the document stated them.
*
* The window the report printed beside a value is carried onto the reading, not
* dropped. Until now it only seeded a freshly minted catalog marker, which meant
* that for every analyte the user already had, the range printed on the report
* was read and then thrown away. It now lands on the row's `sourceReference*`
* columns and governs that reading's verdict.
*/
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
import {
resolveOrMintBiomarker,
type MintedBiomarker,
} from "@/lib/labs/biomarker-store";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
isUnreadableRange,
parseReferenceRange,
} from "@/lib/labs/parse-reference-range";
import { annotate } from "@/lib/logging/context";
import type { ExtractedFact } from "@/generated/prisma/client";
import type {
ConditionFactData,
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 {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = "FactCommitError";
this.code = code;
}
}
export interface CommittedRecordRef {
recordType: "labResult" | "illnessEpisode" | "medication";
recordId: string;
}
/**
* 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 && isDateOnlyKey(date)) return dateOnlyAtNoonUtc(date);
return new Date();
}
/**
* v1.43 — the marker the person picked for an OBSERVATION on the
* review screen, and whether to remember the label as its alias.
*/
export interface ObservationChoice {
biomarkerId?: string;
rememberAlias?: boolean;
}
async function commitObservation(
userId: string,
data: ObservationFactData,
choice: ObservationChoice = {},
): Promise<CommittedRecordRef> {
const isQualitative = typeof data.value !== "number";
// A numeric reading needs a unit so the catalog has something to mint with —
// we never coerce or assume one (fail closed; the user adds it on the review
// screen).
if (!isQualitative && (!data.unit || !data.unit.trim())) {
throw new FactCommitError(
"observation.unitRequired",
"A numeric value needs a unit before it can be saved",
);
}
// The printed window, read once. `referenceText` is what the report wrote;
// the parser derives bounds from it where it can, and where it cannot the
// string still survives onto the row. A document staged before the field
// existed has no text, and the numeric bounds the model reported are then
// the whole of what it stated.
const printed = parseReferenceRange(data.referenceText, data.unit);
const rawLow = printed?.low ?? data.referenceLow;
const rawHigh = printed?.high ?? data.referenceHigh;
// Last gate on an impossible window, and the one that matters for facts
// already sitting in the table: a document staged before the extraction
// schema learned this rule still carries whatever the model reported, and it
// arrives here on confirmation. Dropping beats swapping — the pair says the
// transcription is wrong, not which of the two numbers is.
const transposed = rawLow !== null && rawHigh !== null && rawLow > rawHigh;
const sourceLow = transposed ? null : rawLow;
const sourceHigh = transposed ? null : rawHigh;
const sourceText = printed?.text ?? null;
// A window the report printed and the parser could not read is the case that
// used to pass without a trace: the reading is then judged against the
// catalog band, or against nothing, and no surface says the lab had stated
// something else. Count it here so an unreadable notation — a language the
// prose table does not carry, a layout nobody anticipated — shows up as a
// number on a dashboard instead of as silence. The string itself is the
// user's document and stays out of the event.
if (isUnreadableRange(printed)) {
annotate({
action: { name: "labs.referenceRange.unreadable" },
meta: { surface: "document.commit", length: printed?.text.length ?? 0 },
});
}
// The same reasoning one step further along: a window that WAS readable and
// cannot be true. Counted rather than merely dropped, because a rising count
// says something about the extraction that no individual row does.
if (transposed) {
annotate({
action: { name: "labs.referenceRange.transposed" },
meta: { surface: "document.commit" },
});
}
// The marker the person chose wins; otherwise the label resolves through
// the account's names, aliases and the catalog, or a marker is minted.
let biomarker: MintedBiomarker;
if (choice.biomarkerId) {
const chosen = await prisma.biomarker.findFirst({
where: { id: choice.biomarkerId, userId },
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
analyteKey: true,
},
});
if (!chosen) {
throw new FactCommitError(
"observation.unknownBiomarker",
"The chosen marker does not exist",
);
}
if (choice.rememberAlias !== false) {
await rememberImportAlias(prisma, userId, chosen, data.label);
}
biomarker = chosen;
} else {
biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : sourceLow,
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
}
// A unit that differs from an EXISTING marker's is converted when the
// marker's analyte is known and the registry has the pair (a document states
// "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document
// stated beside the stored number. Otherwise it fails closed: writing the
// stated number under the catalog's unit would corrupt the reading AND break
// the "transcribe verbatim" contract — so reject the fact and let the user
// reconcile on the review screen, exactly like the `unitRequired` gate. A
// freshly minted marker adopts the document's unit, so it cannot disagree.
// Spelling is compared through the shared normaliser, the same one the
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
let storedValue: number | null = isQualitative
? null
: (data.value as number);
let storedSourceLow = isQualitative ? null : sourceLow;
let storedSourceHigh = isQualitative ? null : sourceHigh;
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
if (!isQualitative) {
const reading = await readingInMarkerUnit(prisma, {
marker: biomarker,
value: data.value as number,
unit: data.unit as string,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
});
if (!reading.ok) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
);
}
storedValue = reading.value;
storedSourceLow = reading.sourceReferenceLow;
storedSourceHigh = reading.sourceReferenceHigh;
if (reading.converted) {
convertedFrom = {
sourceValue: reading.sourceValue,
sourceUnit: reading.sourceUnit,
};
}
}
const created = await prisma.labResult.create({
data: {
userId,
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value: storedValue,
valueText: isQualitative ? data.valueText : null,
unit: biomarker.unit,
sourceValue: convertedFrom?.sourceValue ?? null,
sourceUnit: convertedFrom?.sourceUnit ?? null,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
// The report's own window for THIS reading. A qualitative result has no
// window to record.
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: statedDateOrNow(data.effectiveDate),
source: "DOCUMENT",
noteEncrypted: null,
},
});
invalidateUserHealthScore(userId);
// v1.31.0 — the labs arm of the data-arrival spine. A "panel" has no
// first-class entity in the schema (it is a nullable label plus a `takenAt`),
// so the day-scoped singleton key IS the panel grouping: pasting twelve
// markers from one draw fires twelve emits that collapse into one arrival.
// Hooked HERE rather than in the confirm route because the route's
// `CommittedRecordRef` carries only the id — the draw date and panel label
// are lost by the time it returns.
void emitDataArrival({
userId,
kind: "labs_panel",
newestSampleAt: created.takenAt,
insertedCount: 1,
refId: created.panel ?? undefined,
source: "document",
}).catch(() => {});
return { recordType: "labResult", recordId: created.id };
}
async function commitCondition(
userId: string,
data: ConditionFactData,
): Promise<CommittedRecordRef> {
// Reproduce the stated status + code verbatim in the note — never interpret
// it into the `type` / `lifecycle` enums (that would assign meaning). The
// type stays OTHER so the diagnosis is recorded, not categorised.
const noteParts: string[] = [];
if (data.clinicalStatus) noteParts.push(`Status: ${data.clinicalStatus}`);
if (data.verificationStatus) {
noteParts.push(`Verification: ${data.verificationStatus}`);
}
if (data.code && data.codeSystem) {
noteParts.push(`Code: ${data.codeSystem} ${data.code}`);
}
const note = noteParts.length > 0 ? noteParts.join(" · ") : null;
const created = await prisma.illnessEpisode.create({
data: {
userId,
label: data.label,
type: "OTHER",
lifecycle: "ACUTE",
onsetAt: statedDateOrNow(data.onsetDate),
resolvedAt: null,
parentConditionId: null,
noteEncrypted: note ? encryptToBytes(note) : null,
},
});
return { recordType: "illnessEpisode", recordId: created.id };
}
async function commitMedication(
userId: string,
data: MedicationStatementFactData,
): Promise<CommittedRecordRef> {
const created = await prisma.medication.create({
data: {
userId,
name: data.name,
// The document may not state a dose; record it as unspecified rather
// than invent a number. A blank dose is not allowed by the column.
dose: data.dose && data.dose.trim() ? data.dose.trim() : "unspecified",
// A MedicationStatement is a RECORD of what the patient takes, not a
// prescription action: as-needed (no schedule) with reminders off.
asNeeded: true,
notificationsEnabled: false,
...(data.atcCode ? { atcCode: data.atcCode } : {}),
...(data.rxNormCode ? { rxNormCode: data.rxNormCode } : {}),
},
});
return { recordType: "medication", recordId: created.id };
}
/**
* Commit one approved fact into its structured store. Throws `FactCommitError`
* on a per-fact validation miss (e.g. a numeric observation with no unit) so
* the confirm route can report it without failing the whole batch.
*/
export async function commitApprovedFact(
userId: string,
fact: ExtractedFact,
choice: ObservationChoice = {},
): 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);
}
if (fact.factType === "CONDITION") {
return commitCondition(userId, data as ConditionFactData);
}
return commitMedication(userId, data as MedicationStatementFactData);
}