diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 4605ba42e..f1312e1d7 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -516,7 +516,7 @@ describe("buildFhirDocumentBundle", () => { status: "completed", doseText: "10mg", dose: { value: 10, unit: "mg" }, - injectionSite: "abdomen-left", + injectionSite: "ABDOMEN_LEFT", atcCode: "A10BX10", rxNormCode: "2601723", deliveryForm: "INJECTION", @@ -542,8 +542,22 @@ describe("buildFhirDocumentBundle", () => { code: "mg", }); expect(a.dosage?.text).toBe("10mg"); + // Route carries an additive SNOMED coding (INJECTION → subcutaneous) plus + // the unchanged `.text` anchor. expect(a.dosage?.route?.text).toBe("Injection"); - expect(a.dosage?.site?.text).toBe("abdomen-left"); + expect(a.dosage?.route?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code: "34206005", + display: "Subcutaneous route", + }); + // Site `.text` is the raw enum value (laterality preserved); the SNOMED + // coding collapses to the gross body region. + expect(a.dosage?.site?.text).toBe("ABDOMEN_LEFT"); + expect(a.dosage?.site?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code: "818983003", + display: "Abdomen structure", + }); }); it("maps a skipped intake to a not-done administration with no dosage", () => { @@ -643,6 +657,202 @@ describe("buildFhirDocumentBundle", () => { expect(refs).toContain("MedicationAdministration/medadmin-1"); }); + it("emits the oral SNOMED route coding alongside the unchanged `.text`", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Metformin", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "500mg", + dose: { value: 500, unit: "mg" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const route = administrationsOf(bundle)[0].dosage?.route; + expect(route?.text).toBe("Oral"); + expect(route?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code: "26643006", + display: "Oral route", + }); + }); + + it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => { + const cases: Array<[string, string, string]> = [ + ["ABDOMEN_LEFT", "818983003", "Abdomen structure"], + ["ABDOMEN_RIGHT", "818983003", "Abdomen structure"], + ["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"], + ["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"], + ["THIGH_LEFT", "68367000", "Thigh structure"], + ["THIGH_RIGHT", "68367000", "Thigh structure"], + ["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"], + ["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"], + ]; + for (const [site, code, display] of cases) { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:12:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: site, + atcCode: null, + rxNormCode: null, + deliveryForm: "INJECTION", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const fhirSite = administrationsOf(bundle)[0].dosage?.site; + // Laterality / quadrant survives verbatim on the `.text` anchor. + expect(fhirSite?.text).toBe(site); + expect(fhirSite?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code, + display, + }); + } + }); + + it("emits no route for an unknown delivery form and no site when absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mystery", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "1 unit", + dose: { value: 1, unit: "unit" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "TOPICAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const dosage = administrationsOf(bundle)[0].dosage; + expect(dosage?.route).toBeUndefined(); + expect(dosage?.site).toBeUndefined(); + }); + + it("emits exactly the WHO ATC coding by default (no BfArM appended)", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", 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?.some((c) => c.system === "http://fhir.de/CodeSystem/bfarm/atc"), + ).toBe(false); + }); + + it("appends the BfArM ATC coding AFTER the WHO entry when germanAtc is on", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", schedules: [] }, + ], + medicationAdministrations: [ + { + medicationName: "Empagliflozin", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: null, + atcCode: "A10BK03", + rxNormCode: "1545150", + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + { germanAtc: true }, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const stmtCoding = stmt?.medicationCodeableConcept.coding; + // WHO stays coding[0], byte-identical. + expect(stmtCoding?.[0]).toEqual({ + system: "http://www.whocc.no/atc", + code: "A10BK03", + display: "Empagliflozin", + }); + expect(stmtCoding?.[1]).toEqual({ + system: "http://fhir.de/CodeSystem/bfarm/atc", + code: "A10BK03", + display: "Empagliflozin", + }); + + // The administration concept reflects the flag identically; RxNorm follows. + const adminCoding = + administrationsOf(bundle)[0].medicationCodeableConcept.coding; + expect(adminCoding?.[0].system).toBe("http://www.whocc.no/atc"); + expect(adminCoding?.[1].system).toBe( + "http://fhir.de/CodeSystem/bfarm/atc", + ); + expect(adminCoding?.[2]).toEqual({ + system: "http://www.nlm.nih.gov/research/umls/rxnorm", + code: "1545150", + }); + }); + + it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }], + }), + { insuranceNumber: null }, + FIXED_NOW, + { germanAtc: true }, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + // Collapses to the text-only concept exactly as before. + expect(stmt?.medicationCodeableConcept.coding).toBeUndefined(); + expect(stmt?.medicationCodeableConcept.text).toBe("Herbal mix"); + }); + 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 2dea9f6ca..0636ca0a2 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -69,22 +69,48 @@ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; * free-text `.text` (the user's medication name) stays the anchor. */ const ATC_SYSTEM = "http://www.whocc.no/atc"; +/** + * German national ATC URI maintained by the BfArM. Same ATC classification + * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never + * a replacement) when a German-region export is requested, so the WHO entry + * stays first and byte-identical for every consumer. + */ +const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc"; const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; +/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */ +const SNOMED_SYSTEM = "http://snomed.info/sct"; + +/** Options threaded from the export route into the builder. Additive, all defaulted. */ +export interface FhirBuildOptions { + /** + * When true, additionally emit the German BfArM ATC URI alongside the WHO + * entry on each medication concept. The WHO coding stays first and + * byte-identical; this only appends a second URI for the same leaf code. + * Defaults off; the route turns it on for a German-region export. + */ + germanAtc?: boolean; +} /** * 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. + * When `germanAtc` is set, the same ATC leaf code is also published under + * the BfArM URI AFTER the WHO entry — additive, never reordering WHO. */ function medicationConcept( name: string, atcCode: string | null | undefined, rxNormCode: string | null | undefined, + germanAtc: boolean, ): FhirCodeableConcept { const coding: NonNullable = []; if (atcCode) { coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + if (germanAtc) { + coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name }); + } } if (rxNormCode) { coding.push({ system: RXNORM_SYSTEM, code: rxNormCode }); @@ -122,22 +148,71 @@ function doseQuantity(value: number, unit: string): { } /** - * 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. + * Route of administration derived from the medication's delivery form, + * carrying an additive SNOMED CT `coding` alongside the existing `.text` + * anchor. HealthLog injections are subcutaneous (the injection-site picker + * exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to + * the subcutaneous route. Returns `undefined` for an unknown / absent form + * so no empty route is emitted. */ +const ROUTE_SNOMED: Record = { + ORAL: { code: "26643006", display: "Oral route" }, + INJECTION: { code: "34206005", display: "Subcutaneous route" }, +}; + function routeConcept( deliveryForm: string | null, ): FhirCodeableConcept | undefined { - switch (deliveryForm) { - case "ORAL": - return { text: "Oral" }; - case "INJECTION": - return { text: "Injection" }; - default: - return undefined; - } + const text = + deliveryForm === "ORAL" + ? "Oral" + : deliveryForm === "INJECTION" + ? "Injection" + : undefined; + if (!text) return undefined; + const snomed = ROUTE_SNOMED[deliveryForm as string]; + return snomed + ? { + coding: [ + { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, + ], + text, + } + : { text }; +} + +/** + * Administration body-site keyed on the raw `InjectionSite` enum value, + * carrying an additive SNOMED CT body-region `coding` alongside the `.text` + * anchor. The map collapses the eight enum members to three gross body-region + * concepts (abdomen / thigh / upper arm); laterality (left/right) and the + * abdominal quadrant are NOT lateralised SNOMED concepts here — they are + * preserved verbatim in the human-readable `.text` (the raw enum value), so + * no information is lost. + */ +const SITE_SNOMED: Record = { + ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" }, + THIGH_LEFT: { code: "68367000", display: "Thigh structure" }, + THIGH_RIGHT: { code: "68367000", display: "Thigh structure" }, + UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" }, + UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" }, +}; + +function siteConcept(injectionSite: string): FhirCodeableConcept { + const snomed = SITE_SNOMED[injectionSite]; + // Preserve the full enum value (incl. laterality) as the readable anchor. + const text = injectionSite; + return snomed + ? { + coding: [ + { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, + ], + text, + } + : { text }; } /** Escape the five XML-significant characters for the xhtml narrative. */ @@ -198,7 +273,9 @@ export function buildFhirDocumentBundle( data: DoctorReportData, identity: FhirPatientIdentity, now: Date = new Date(), + options: FhirBuildOptions = {}, ): FhirBundle { + const germanAtc = options.germanAtc ?? false; const patientId = "patient-1"; const patientRef: FhirReference = { reference: `Patient/${patientId}` }; const entries: FhirBundleEntry[] = []; @@ -479,6 +556,7 @@ export function buildFhirDocumentBundle( med.name, med.atcCode, med.rxNormCode, + germanAtc, ), subject: patientRef, }; @@ -509,15 +587,15 @@ export function buildFhirDocumentBundle( admin.medicationName, admin.atcCode, admin.rxNormCode, + germanAtc, ), 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. + // free-text dose + route + site alongside it. Route and site each carry + // an additive SNOMED coding plus the existing `.text` anchor. if (admin.dose) { const dosage: FhirDosage = { dose: doseQuantity(admin.dose.value, admin.dose.unit), @@ -525,7 +603,7 @@ export function buildFhirDocumentBundle( 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 }; + if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite); resource.dosage = dosage; } diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 649e6f54f..df65bbc80 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -86,9 +86,9 @@ export interface FhirDosage { * 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 of administration: an additive SNOMED `coding` plus the `.text` anchor. */ route?: FhirCodeableConcept; - /** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */ + /** Administration body-site (e.g. injection site): additive SNOMED `coding` plus `.text`. */ site?: FhirCodeableConcept; }