feat(fhir): add additive SNOMED route and site codings to MedicationAdministration

Emit a SNOMED CT coding alongside the existing .text anchor on the
administration dosage. Route maps ORAL to 26643006 (Oral route) and
INJECTION to 34206005 (Subcutaneous route, the form HealthLog injections
take). Site keys on the eight InjectionSite enum values, collapsing to the
three gross body-region concepts (abdomen 818983003, thigh 68367000, upper
arm 40983000); laterality and the abdominal quadrant stay verbatim in the
.text anchor rather than inventing lateralised concepts.

Referencing concept ids in a FHIR instance is not redistributing the
licensed SNOMED distribution, so this is safe to ship. The .text anchor is
unchanged, so a consumer without a SNOMED binding is unaffected.

Reconcile the dose fixture to the real uppercase enum value the live
builder emits (the previous lowercased value never matched production).
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:22:50 +02:00
1 parent 95064b405a
commit 9990815efe
3 files changed
+308 -20

No files matched your search

+212 -2
View File
@@ -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 }),
+94 -16
View File
@@ -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<FhirCodeableConcept["coding"]> = [];
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<string, { code: string; display: string }> = {
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<string, { code: string; display: string }> = {
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;
}
+2 -2
View File
@@ -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;
}