mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(fhir): add a MedicationAdministration per acted intake
Emit one `MedicationAdministration` per intake the user actually actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`), `not-done` for an explicit skip (`effectiveDateTime = scheduledFor`). Pending / missed slots and soft-deleted tombstones are never exported, so the record only asserts administrations that happened. Each administration reuses the statement's ATC/RxNorm + text concept, so it is self-describing without a cross-resource reference. A `dosage` is emitted only when a structured dose Quantity is available (resolved from the dose-change history in effect at the administration instant) — a dosage carrying only `.text` would violate the R4 dose-or-rate invariant. Route + injection site ride along as text-only concepts. The administrations are referenced from the existing Medications section, so the document's two-section graph is preserved. `FhirMedicationAdministration` is new; `FhirDosage` gains `dose`, `route`, and `site`. The source rows come from the same aggregator the PDF consumes, keeping FHIR and PDF aligned by construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d)
This commit is contained in:
1 parent
2fcd2e75f5
commit
ca8781867b
4 files changed
+424
-3
No files matched your search
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* v1.9.0 — `medicationAdministrations` aggregation + drug-code carry.
|
||||
*
|
||||
* Asserts the source rows the FHIR `MedicationAdministration` builder
|
||||
* consumes are assembled correctly from `MedicationIntakeEvent`:
|
||||
* - a taken row → `completed` with `effectiveAt = takenAt`,
|
||||
* - an explicit skip → `not-done` with `effectiveAt = scheduledFor`,
|
||||
* - a pending / missed slot (no `takenAt`, not skipped) → omitted,
|
||||
* - a soft-deleted tombstone → omitted,
|
||||
* - the structured dose-in-effect resolved from `MedicationDoseChange`,
|
||||
* - the medication's `atcCode` / `rxNormCode` carried onto both the
|
||||
* `medications[]` concept and each administration row.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const RANGE = {
|
||||
start: new Date("2026-01-01T00:00:00.000Z"),
|
||||
end: new Date("2026-06-01T00:00:00.000Z"),
|
||||
days: 152,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("medicationAdministrations aggregation (v1.9.0)", () => {
|
||||
it("maps acted intakes, omits missed + tombstoned, resolves dose-in-effect", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: { username: "admin-agg", email: "admin-agg@example.test" },
|
||||
});
|
||||
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Mounjaro",
|
||||
dose: "10mg",
|
||||
active: true,
|
||||
treatmentClass: "GLP1",
|
||||
deliveryForm: "INJECTION",
|
||||
atcCode: "A10BX10",
|
||||
rxNormCode: "2601723",
|
||||
},
|
||||
});
|
||||
|
||||
// Dose-change history: 5mg from Feb, stepped to 10mg from Apr.
|
||||
await prisma.medicationDoseChange.createMany({
|
||||
data: [
|
||||
{
|
||||
medicationId: med.id,
|
||||
effectiveFrom: new Date("2026-02-01T00:00:00.000Z"),
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
medicationId: med.id,
|
||||
effectiveFrom: new Date("2026-04-01T00:00:00.000Z"),
|
||||
doseValue: 10,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// A taken dose in March (dose-in-effect = 5mg) with a site.
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-03-15T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-03-15T08:12:00.000Z"),
|
||||
injectionSite: "ABDOMEN_LEFT",
|
||||
},
|
||||
});
|
||||
// A skipped dose in April.
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-04-15T08:00:00.000Z"),
|
||||
skipped: true,
|
||||
},
|
||||
});
|
||||
// A missed slot (no takenAt, not skipped) — must be omitted.
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-05-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
// A soft-deleted (tombstoned) taken dose — must be omitted.
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: new Date("2026-05-10T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-05-10T08:05:00.000Z"),
|
||||
deletedAt: new Date("2026-05-11T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await collectDoctorReportData(user.id, RANGE);
|
||||
|
||||
// Drug codes carried onto the medication concept source.
|
||||
expect(data.medications[0].atcCode).toBe("A10BX10");
|
||||
expect(data.medications[0].rxNormCode).toBe("2601723");
|
||||
|
||||
const admins = data.medicationAdministrations ?? [];
|
||||
// Only the taken + skipped rows survive.
|
||||
expect(admins).toHaveLength(2);
|
||||
|
||||
const taken = admins.find((a) => a.status === "completed");
|
||||
expect(taken).toBeDefined();
|
||||
expect(taken?.effectiveAt).toBe("2026-03-15T08:12:00.000Z");
|
||||
expect(taken?.injectionSite).toBe("ABDOMEN_LEFT");
|
||||
expect(taken?.deliveryForm).toBe("INJECTION");
|
||||
expect(taken?.atcCode).toBe("A10BX10");
|
||||
// Dose-in-effect at 2026-03-15 is the 5mg step (not yet 10mg).
|
||||
expect(taken?.dose).toEqual({ value: 5, unit: "mg" });
|
||||
|
||||
const skipped = admins.find((a) => a.status === "not-done");
|
||||
expect(skipped).toBeDefined();
|
||||
// A skip uses scheduledFor and records no consumed dose.
|
||||
expect(skipped?.effectiveAt).toBe("2026-04-15T08:00:00.000Z");
|
||||
expect(skipped?.dose).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user