feat(fhir): emit ATC + RxNorm codings on the medication concept

The health-record export's `MedicationStatement.medicationCodeableConcept`
gains additive `coding[]` entries when the medication carries a stored
code: ATC primary (`http://www.whocc.no/atc`), RxNorm secondary
(`http://www.nlm.nih.gov/research/umls/rxnorm`). The free-text name
stays the `.text` anchor and the concept collapses to exactly the
prior text-only shape when no code is present — the export never
machine-guesses a code, only emits what the user stored.

The aggregator now carries `atcCode` / `rxNormCode` on `medications[]`
and assembles the acted-intake source rows
(`medicationAdministrations`) the administration builder consumes, so
the FHIR export keeps describing the same numbers as the PDF by
construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 740c86b3a830efa7e76d69b29ae376addeee6811)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:45:23 +02:00
1 parent 0a8c6e307c
commit 2fcd2e75f5
3 files changed
+242 -2

No files matched your search

+127 -1
View File
@@ -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,
};
@@ -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 }),
+38 -1
View File
@@ -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<FhirCodeableConcept["coding"]> = [];
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 }];