Files
HealthLog/src/lib/documents/commit.ts
T
Marc-André Bombeck 2321ce29db fix(insights): route alert-card compliance through the cadence-aware engine + deep data cleanups
The insights alert cards computed medication compliance with the naive
`schedules.length × days` denominator, so a fully-adherent weekly
injectable read ~14% and fired a false low-compliance alert (and a paused
medication mis-fired the same way). Route the alert path through the
canonical `calculateCompliance` engine like every other compliance
surface — pause eras, schedule revisions and dose windows honoured.

Document confirm: committing a numeric observation against an existing
biomarker silently replaced the stated unit with the catalog's, with no
conversion — a stated "6.1 mmol/L" against a mg/dL marker committed
"6.1 mg/dL". Reject on a unit mismatch (fail closed) so the user
reconciles on the review screen, the same posture as the unit-required
guard.

Wire grip strength, waist (with waist-to-height) and pain into the
briefing feature set and the grounding allow-set so the clinical-depth
signals reach the daily narrative; the mental-health screeners stay
excluded by construction.

Biomarker detail: fall back to a generic description for catalog-less
markers, and keep the heading + description with a retry on the error
state. Collapse the medication External-API and Lifecycle groups by
default. Re-enable the insights scroll-to-top regression and assert the
shipped main-content reset.

Align the EN haemoglobin/haematocrit display names to British spelling.

Drop the redundant `(userId, deletedAt)` index on inbound documents — a
strict prefix of the new three-column index. Trim the dead
MedicalDisclaimer variants to the one live `dataPosture`. Move the
crisis-resources card off the retired amber tint to a calm
destructive-subtle border. Add a registry invariant asserting every
biomarker signal key resolves to a real catalog slug.
2026-06-29 00:37:39 +02:00

184 lines
7.2 KiB
TypeScript

/**
* v1.25 (W-DOCS-IN) — 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.
*/
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import type { ExtractedFact } from "@/generated/prisma/client";
import type {
ConditionFactData,
MedicationStatementFactData,
ObservationFactData,
} from "@/lib/validations/inbound-documents";
/** 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;
}
/** Parse a stated YYYY-MM-DD into a UTC instant, or fall back to now. */
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`);
}
return new Date();
}
async function commitObservation(
userId: string,
data: ObservationFactData,
): 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",
);
}
const biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
referenceLow: isQualitative ? null : data.referenceLow,
referenceHigh: isQualitative ? null : data.referenceHigh,
panel: null,
});
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
// minted marker adopts the document's unit, so this only bites when the
// stated unit disagrees with the catalog's authoritative one (e.g. a
// document states "6.1 mmol/L" against a marker stored in mg/dL). 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.
if (!isQualitative) {
const stated = (data.unit as string).trim().toLowerCase();
const target = biomarker.unit.trim().toLowerCase();
if (stated !== target) {
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.`,
);
}
}
const created = await prisma.labResult.create({
data: {
userId,
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value: isQualitative ? null : (data.value as number),
valueText: isQualitative ? data.valueText : null,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
takenAt: statedDateOrNow(data.effectiveDate),
source: "DOCUMENT",
noteEncrypted: null,
},
});
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,
): 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);
}
if (fact.factType === "CONDITION") {
return commitCondition(userId, data as ConditionFactData);
}
return commitMedication(userId, data as MedicationStatementFactData);
}