mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
184 lines
7.2 KiB
TypeScript
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);
|
|
}
|