From ca8781867b0f40a7d71ea37047d946ca179771ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:40:41 +0200 Subject: [PATCH] feat(fhir): add a MedicationAdministration per acted intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) (cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d) --- src/lib/fhir/__tests__/build-bundle.test.ts | 149 ++++++++++++++++++ src/lib/fhir/build-bundle.ts | 111 ++++++++++++- src/lib/fhir/types.ts | 33 ++++ .../medication-administrations.test.ts | 134 ++++++++++++++++ 4 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 tests/integration/medication-administrations.test.ts diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 7767a5f5e..62c840260 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -4,11 +4,23 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { FhirObservation, FhirMedicationStatement, + FhirMedicationAdministration, FhirPatient, FhirCoverage, FhirDiagnosticReport, } from "../types"; +function administrationsOf( + bundle: ReturnType, +): FhirMedicationAdministration[] { + return bundle.entry + .map((e) => e.resource) + .filter( + (r): r is FhirMedicationAdministration => + r.resourceType === "MedicationAdministration", + ); +} + function coverageOf( bundle: ReturnType, ): FhirCoverage | undefined { @@ -413,6 +425,143 @@ describe("buildFhirDocumentBundle", () => { expect(coding?.[0].code).toBe("C09AA05"); }); + it("maps a taken intake to a completed MedicationAdministration with a structured dose", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:12:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: "abdomen-left", + atcCode: "A10BX10", + rxNormCode: "2601723", + deliveryForm: "INJECTION", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const admins = administrationsOf(bundle); + expect(admins).toHaveLength(1); + const a = admins[0]; + expect(a.status).toBe("completed"); + expect(a.effectiveDateTime).toBe("2026-04-30T08:12:00.000Z"); + // Self-describing concept reuses the ATC/RxNorm coding + text anchor. + expect(a.medicationCodeableConcept.text).toBe("Mounjaro"); + expect(a.medicationCodeableConcept.coding?.[0].code).toBe("A10BX10"); + // Structured dose satisfies the R4 dose-or-rate invariant. + expect(a.dosage?.dose).toEqual({ + value: 10, + unit: "mg", + system: "http://unitsofmeasure.org", + code: "mg", + }); + expect(a.dosage?.text).toBe("10mg"); + expect(a.dosage?.route?.text).toBe("Injection"); + expect(a.dosage?.site?.text).toBe("abdomen-left"); + }); + + it("maps a skipped intake to a not-done administration with no dosage", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Ramipril", + effectiveAt: "2026-04-29T08:00:00.000Z", + status: "not-done", + doseText: "5mg", + dose: null, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const admins = administrationsOf(bundle); + expect(admins).toHaveLength(1); + expect(admins[0].status).toBe("not-done"); + expect(admins[0].effectiveDateTime).toBe("2026-04-29T08:00:00.000Z"); + // No structured dose → no dosage block (R4 dose-or-rate invariant). + expect(admins[0].dosage).toBeUndefined(); + // Text-only concept when the medication has no codes. + expect(admins[0].medicationCodeableConcept.coding).toBeUndefined(); + expect(admins[0].medicationCodeableConcept.text).toBe("Ramipril"); + }); + + it("omits the dosage when a taken dose has no structured dose Quantity", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Vitamin D3", + effectiveAt: "2026-04-30T20:00:00.000Z", + status: "completed", + doseText: "1000 IU", + dose: null, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const a = administrationsOf(bundle)[0]; + expect(a.status).toBe("completed"); + // A dosage with only `.text` would violate the dose-or-rate + // invariant; the builder omits the dosage entirely instead. + expect(a.dosage).toBeUndefined(); + }); + + it("emits no MedicationAdministration when there are no acted intakes", () => { + const bundle = buildFhirDocumentBundle( + makeData({ medicationAdministrations: [] }), + { insuranceNumber: null }, + FIXED_NOW, + ); + expect(administrationsOf(bundle)).toHaveLength(0); + }); + + it("references the administrations from the Medications composition section", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:12:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "INJECTION", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource; + expect(composition.resourceType).toBe("Composition"); + const medSection = + composition.resourceType === "Composition" + ? composition.section?.find((s) => s.title === "Medications") + : undefined; + const refs = medSection?.entry?.map((e) => e.reference) ?? []; + expect(refs).toContain("MedicationAdministration/medadmin-1"); + }); + it("omits the mood Observation when mood is null (privacy default)", () => { const bundle = buildFhirDocumentBundle( makeData({ mood: null }), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 785da312f..7227ba4d5 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -39,6 +39,8 @@ import type { FhirCodeableConcept, FhirObservation, FhirMedicationStatement, + FhirMedicationAdministration, + FhirDosage, FhirPatient, FhirCoverage, FhirOrganization, @@ -90,6 +92,54 @@ function medicationConcept( return coding.length > 0 ? { coding, text: name } : { text: name }; } +/** + * v1.9.0 — UCUM codes for the dose units HealthLog stores. The display + * `unit` is always the user's original string; the UCUM `code` is set + * only for an unambiguous mapping so a consumer that resolves UCUM never + * sees a guessed code. An unmapped unit drops `code` (and `system`) and + * keeps just the human-readable `unit` — conformant, just not coded. + */ +const UCUM_DOSE_CODES: Record = { + mg: "mg", + g: "g", + mcg: "ug", + µg: "ug", + ug: "ug", + ml: "mL", + mL: "mL", +}; + +function doseQuantity(value: number, unit: string): { + value: number; + unit: string; + system?: string; + code?: string; +} { + const ucum = UCUM_DOSE_CODES[unit]; + return ucum + ? { value, unit, system: UCUM_SYSTEM, code: ucum } + : { value, unit }; +} + +/** + * v1.9.0 — text-only route of administration derived from the + * medication's delivery form. Text-only (no SNOMED licence concern); the + * design doc upgrades to coded routes jointly with iOS later. Returns + * `undefined` for an unknown / absent form so no empty route is emitted. + */ +function routeConcept( + deliveryForm: string | null, +): FhirCodeableConcept | undefined { + switch (deliveryForm) { + case "ORAL": + return { text: "Oral" }; + case "INJECTION": + return { text: "Injection" }; + default: + return undefined; + } +} + /** Escape the five XML-significant characters for the xhtml narrative. */ function escapeXml(value: string): string { return value @@ -434,11 +484,57 @@ export function buildFhirDocumentBundle( medicationRefs.push({ reference: `MedicationStatement/${id}` }); } + // --- MedicationAdministration per acted intake ------------------------- + // One resource per intake the user actually actioned: `completed` + // (taken) or `not-done` (explicitly skipped). Pending / missed slots and + // soft-deleted tombstones are excluded upstream by the aggregator. The + // concept reuses the same ATC/RxNorm coding as the statement so each + // administration is self-describing without resolving a reference (no + // `partOf` / `request` coupling — see the v1.9.0 design doc). A `dosage` + // is emitted ONLY when a structured `dose` Quantity is available; a + // dosage with only `.text` would violate the R4 dose-or-rate invariant. + const administrationRefs: FhirReference[] = []; + let adminSeq = 0; + for (const admin of data.medicationAdministrations ?? []) { + adminSeq += 1; + const id = `medadmin-${adminSeq}`; + const resource: FhirMedicationAdministration = { + resourceType: "MedicationAdministration", + id, + status: admin.status, + medicationCodeableConcept: medicationConcept( + admin.medicationName, + admin.atcCode, + admin.rxNormCode, + ), + subject: patientRef, + effectiveDateTime: admin.effectiveAt, + }; + + // Dosage: only when a structured dose Quantity exists. Carry the + // free-text dose + route + site alongside it. Text-only route/site + // (no SNOMED licence concern); the design doc upgrades to coded + // routes jointly with iOS later. + if (admin.dose) { + const dosage: FhirDosage = { + dose: doseQuantity(admin.dose.value, admin.dose.unit), + }; + if (admin.doseText) dosage.text = admin.doseText; + const route = routeConcept(admin.deliveryForm); + if (route) dosage.route = route; + if (admin.injectionSite) dosage.site = { text: admin.injectionSite }; + resource.dosage = dosage; + } + + entries.push({ fullUrl: `urn:uuid:${id}`, resource }); + administrationRefs.push({ reference: `MedicationAdministration/${id}` }); + } + // --- Composition (leading "cover" resource) ---------------------------- const narrativeText = [ `Health record for ${escapeXml(displayName ?? "patient")}.`, `Reporting period ${data.period.start.slice(0, 10)} to ${data.period.end.slice(0, 10)}.`, - `${observationRefs.length} observation(s), ${medicationRefs.length} medication(s).`, + `${observationRefs.length} observation(s), ${medicationRefs.length} medication(s), ${administrationRefs.length} administration(s).`, ].join(" "); const composition: FhirComposition = { @@ -467,8 +563,17 @@ export function buildFhirDocumentBundle( text: { status: "generated", div: `
${escapeXml(narrativeText)}
` }, entry: [patientRef, ...observationRefs], }, - ...(medicationRefs.length > 0 - ? [{ title: "Medications", entry: medicationRefs }] + // Medications section carries both the active-medication + // statements and the per-dose administration records (v1.9.0), so + // the iOS two-section graph ("Vital signs" + "Medications") is + // preserved without adding a third section. + ...(medicationRefs.length > 0 || administrationRefs.length > 0 + ? [ + { + title: "Medications", + entry: [...medicationRefs, ...administrationRefs], + }, + ] : []), ], }; diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 7b7484f66..55a375235 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -79,6 +79,17 @@ export interface FhirObservation { export interface FhirDosage { text?: string; + /** + * v1.9.0 — structured dose `Quantity`. On a `MedicationAdministration` + * a `dosage` SHALL carry at least one of `dose` or `rate` (R4 + * invariant), so the builder only emits a `dosage` block when a `dose` + * is available; a text-only dose stays on the `MedicationStatement`. + */ + dose?: FhirQuantity; + /** v1.9.0 — route of administration. Text-only by default (no SNOMED licence concern). */ + route?: FhirCodeableConcept; + /** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */ + site?: FhirCodeableConcept; } export interface FhirPeriod { @@ -96,6 +107,27 @@ export interface FhirMedicationStatement { dosage?: FhirDosage[]; } +export interface FhirMedicationAdministration { + resourceType: "MedicationAdministration"; + id: string; + /** + * v1.9.0 — only `completed` (a taken dose) or `not-done` (an explicit + * skip) are emitted; the exporter never asserts an administration for a + * pending / missed slot or a soft-deleted tombstone. + */ + status: "completed" | "not-done"; + /** Self-describing concept — the same ATC/RxNorm + text the statement carries. */ + medicationCodeableConcept: FhirCodeableConcept; + subject: FhirReference; + /** + * `takenAt` for a `completed` administration, `scheduledFor` (the + * intended instant) for a `not-done` skip. + */ + effectiveDateTime: string; + /** Single dosage (R4 `0..1`). Present only when a structured `dose` exists. */ + dosage?: FhirDosage; +} + export interface FhirDiagnosticReport { resourceType: "DiagnosticReport"; id: string; @@ -152,6 +184,7 @@ export type FhirResource = | FhirCoverage | FhirObservation | FhirMedicationStatement + | FhirMedicationAdministration | FhirDiagnosticReport; export interface FhirBundleEntry { diff --git a/tests/integration/medication-administrations.test.ts b/tests/integration/medication-administrations.test.ts new file mode 100644 index 000000000..f1b7ea5f7 --- /dev/null +++ b/tests/integration/medication-administrations.test.ts @@ -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(); + }); +});