mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
0a8c6e307c
commit
2fcd2e75f5
3 files changed
+242
-2
No files matched your search
@@ -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 }),
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
Reference in new issue
Block a user