diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index a40206ade..7924a0a08 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -88,12 +88,47 @@ export interface DoctorReportData { medications: Array<{ name: string; dose: string; + // v1.9.0 — optional user/clinician-asserted drug-classification + // codes. Carried here so the FHIR exporter can emit a coded + // `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL + // when no code was captured — the exporter then emits the pre-v1.9.0 + // text-only concept. Optional in the type so pre-v1.9.0 fixtures + // still typecheck. + atcCode?: string | null; + rxNormCode?: string | null; schedules: Array<{ windowStart: string; windowEnd: string; label: string | null; }>; }>; + /** + * v1.9.0 — acted medication-intake events over the report window, fed + * to the FHIR `MedicationAdministration` builder. One entry per intake + * row the user actually actioned (taken OR explicitly skipped); + * pending / missed / soft-deleted rows are excluded upstream so the + * exporter never asserts an administration that did not happen. The + * `dose` is the medication's structured dose-in-effect at + * `effectiveAt` (from `MedicationDoseChange`) when one exists, else + * NULL. Optional so pre-v1.9.0 fixtures still typecheck. + */ + medicationAdministrations?: Array<{ + medicationName: string; + /** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */ + effectiveAt: string; + status: "completed" | "not-done"; + /** Free-text dose label (the medication's `dose`), mirrors the statement. */ + doseText: string | null; + /** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */ + dose: { value: number; unit: string } | null; + /** Injection site, when the row recorded one. */ + injectionSite: string | null; + /** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */ + atcCode: string | null; + rxNormCode: string | null; + /** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */ + deliveryForm: string | null; + }>; mood: DoctorReportMood | null; /** * v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at @@ -310,7 +345,21 @@ export async function collectDoctorReportData( prisma.medicationIntakeEvent.findMany({ // v1.7.0 sync — exclude tombstoned rows from the doctor report. where: { userId, deletedAt: null, scheduledFor: { gte: start, lte: end } }, - include: { medication: { select: { name: true } } }, + include: { + // v1.9.0 — carry the medication identity + codes + delivery + // form so the FHIR MedicationAdministration builder can emit a + // self-describing `medicationCodeableConcept` and a route. + medication: { + select: { + id: true, + name: true, + dose: true, + atcCode: true, + rxNormCode: true, + deliveryForm: true, + }, + }, + }, orderBy: { scheduledFor: "asc" }, }), // Mood data: zero DB read when the user opted out. This is the @@ -386,6 +435,79 @@ export async function collectDoctorReportData( } } + // v1.9.0 — MedicationAdministration source rows. One entry per acted + // intake (taken OR explicitly skipped); pending / missed rows are + // dropped so the FHIR export never asserts an administration that did + // not happen, and tombstoned rows are already excluded by the query + // `deletedAt: null` predicate. The structured `dose` is resolved from + // the medication's `MedicationDoseChange` history — the latest change + // effective at or before the administration instant — when one exists. + // + // Dose-change history is loaded on the `medications` array (active + // meds only); a per-medicationId index lets the resolver run in O(1) + // lookups + a short linear scan over the (typically small) change list. + const doseChangesByMedId = new Map< + string, + Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }> + >(); + for (const m of medications) { + doseChangesByMedId.set( + m.id, + m.doseChanges.map((dc) => ({ + effectiveFrom: dc.effectiveFrom, + doseValue: dc.doseValue, + doseUnit: dc.doseUnit, + })), + ); + } + const resolveDoseInEffect = ( + medicationId: string, + at: Date, + ): { value: number; unit: string } | null => { + const changes = doseChangesByMedId.get(medicationId); + if (!changes || changes.length === 0) return null; + // `doseChanges` are loaded ordered by `effectiveFrom asc`; take the + // last one whose effectiveFrom is <= the administration instant. + let inEffect: { value: number; unit: string } | null = null; + for (const c of changes) { + if (c.effectiveFrom.getTime() <= at.getTime()) { + inEffect = { value: c.doseValue, unit: c.doseUnit }; + } else { + break; + } + } + return inEffect; + }; + + const medicationAdministrations: NonNullable< + DoctorReportData["medicationAdministrations"] + > = []; + for (const event of intakeEvents) { + // Only acted rows: a taken dose (completed) or an explicit skip + // (not-done). A scheduled-but-unconfirmed ("missed") slot is not an + // administration event and is omitted entirely. + const isTaken = event.takenAt !== null; + if (!isTaken && !event.skipped) continue; + const effectiveAt = isTaken + ? (event.takenAt as Date) + : event.scheduledFor; + medicationAdministrations.push({ + medicationName: event.medication.name, + effectiveAt: effectiveAt.toISOString(), + status: isTaken ? "completed" : "not-done", + doseText: event.medication.dose || null, + // Structured dose only meaningful for a taken dose with a + // dose-change history; a skip records no dose consumed. + dose: isTaken + ? resolveDoseInEffect(event.medication.id, effectiveAt) + : null, + injectionSite: event.injectionSite ?? null, + atcCode: event.medication.atcCode ?? null, + rxNormCode: event.medication.rxNormCode ?? null, + deliveryForm: event.medication.deliveryForm ?? null, + }); + } + // Mood summary. const moodScores = moodEntries.map((e) => e.score); const mood: DoctorReportMood | null = @@ -593,12 +715,16 @@ export async function collectDoctorReportData( medications: medications.map((m) => ({ name: m.name, dose: m.dose, + // v1.9.0 — drug-classification codes for the coded FHIR concept. + atcCode: m.atcCode, + rxNormCode: m.rxNormCode, schedules: m.schedules.map((s) => ({ windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label, })), })), + medicationAdministrations, mood, glp1, }; diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 3fb55e4a1..7767a5f5e 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -336,6 +336,83 @@ describe("buildFhirDocumentBundle", () => { expect(meds[0].dosage?.[0].text).toBe("5mg"); }); + it("keeps a text-only medicationCodeableConcept when no codes are stored", () => { + const bundle = buildFhirDocumentBundle( + makeData(), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + // Pre-v1.9.0 shape: text anchor, no coding[]. + expect(stmt?.medicationCodeableConcept.text).toBe("Example Drug"); + expect(stmt?.medicationCodeableConcept.coding).toBeUndefined(); + }); + + it("emits ATC (primary) + RxNorm (secondary) codings when both are stored", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { + name: "Mounjaro", + dose: "10mg", + atcCode: "A10BX10", + rxNormCode: "2601723", + schedules: [], + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const coding = stmt?.medicationCodeableConcept.coding; + expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro"); + expect(coding).toHaveLength(2); + // ATC is primary (first), with the name as display. + expect(coding?.[0]).toEqual({ + system: "http://www.whocc.no/atc", + code: "A10BX10", + display: "Mounjaro", + }); + // RxNorm is secondary, no display. + expect(coding?.[1]).toEqual({ + system: "http://www.nlm.nih.gov/research/umls/rxnorm", + code: "2601723", + }); + }); + + it("emits only the ATC coding when RxNorm is absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const coding = stmt?.medicationCodeableConcept.coding; + expect(coding).toHaveLength(1); + expect(coding?.[0].system).toBe("http://www.whocc.no/atc"); + expect(coding?.[0].code).toBe("C09AA05"); + }); + 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 34a85eb26..785da312f 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -60,6 +60,36 @@ const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10"; /** German insurer institution-number (IKNR) identifier namespace. */ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; +/** + * v1.9.0 — drug-coding system URIs. ATC is the portable WHO default + * (the iOS export emits the identical URI); RxNorm is the secondary US + * coding. Both are additive `coding[]` entries on the same concept; the + * free-text `.text` (the user's medication name) stays the anchor. + */ +const ATC_SYSTEM = "http://www.whocc.no/atc"; +const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; + +/** + * Build a `medicationCodeableConcept` from a medication's free-text name + * plus its optional user-asserted codes. ATC is emitted first (primary), + * RxNorm second (secondary); both are omitted when NULL, collapsing to + * exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code. + */ +function medicationConcept( + name: string, + atcCode: string | null | undefined, + rxNormCode: string | null | undefined, +): FhirCodeableConcept { + const coding: NonNullable = []; + if (atcCode) { + coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + } + if (rxNormCode) { + coding.push({ system: RXNORM_SYSTEM, code: rxNormCode }); + } + return coding.length > 0 ? { coding, text: name } : { text: name }; +} + /** Escape the five XML-significant characters for the xhtml narrative. */ function escapeXml(value: string): string { return value @@ -389,7 +419,14 @@ export function buildFhirDocumentBundle( resourceType: "MedicationStatement", id, status: "active", - medicationCodeableConcept: { text: med.name }, + // v1.9.0 — additive ATC (primary) / RxNorm (secondary) codings when + // the user stored them; falls back to the text-only concept when + // neither code is present (the pre-v1.9.0 shape). + medicationCodeableConcept: medicationConcept( + med.name, + med.atcCode, + med.rxNormCode, + ), subject: patientRef, }; if (med.dose) stmt.dosage = [{ text: med.dose }];