Files
HealthLog/src/lib/fhir/build-bundle.ts
T
Marc-André Bombeck 7ec2555d9a feat(records): map allergies + family history to FHIR R4
Emit `AllergyIntolerance` and `FamilyMemberHistory` resources in the
health-record document bundle. Vendor-blind and non-diagnostic, matching the
illness `Condition` stance: the user's own label rides `code.text` (never a
machine-guessed clinical code); only the FHIR-fixed value sets — allergy
category, clinical/verification status, the v3 RoleCode family-member concept —
carry codings. Every resource is marked patient-reported (allergy
`verificationStatus: unconfirmed` plus an explicit "not a clinical diagnosis"
note) so a clinician's viewer never reads it as an adjudicated finding.

The records are loaded owner-scoped in the export route and folded into the
fhir + package bundle formats with their own Composition sections.
2026-06-28 16:03:15 +02:00

328 lines
12 KiB
TypeScript

/**
* v1.7.0 — HL7 FHIR R4 document-Bundle builder for the health-record export.
*
* Pure function: takes the SAME `DoctorReportData` the PDF renderer consumes
* (assembled by `collectDoctorReportData`) plus the decrypted patient
* identity, and emits a `Bundle` of `type: "document"` — a leading
* `Composition` "cover page", the `Patient`, then one `Observation` per
* selected vital / glucose context / mood + one `MedicationStatement` per
* active medication.
*
* Because it reuses the PDF aggregator, the FHIR export and the PDF describe
* identical numbers by construction (the source-of-truth property the two
* PDF endpoints already share).
*
* v1.11.0 — the per-resource emitters (Observation / MedicationStatement /
* MedicationAdministration / Patient / Coverage, with their LOINC/ATC/SNOMED/
* UCUM codings + the `survey`-category wellness split) live in the shared
* `./resources` module so the FHIR REST search routes reuse the SAME coding
* logic. This builder composes them into the document Bundle.
*
* No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
* (`./types`), matching the project's "hand-rolled over the documented wire"
* convention. The `Composition.text` narrative is escaped plain text, never
* user-supplied HTML (no markdown library, no `dangerouslySetInnerHTML`).
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto";
import {
allergyIntoleranceResources,
familyMemberHistoryResources,
} from "@/lib/fhir/records";
import { LOINC_SYSTEM } from "@/lib/fhir/loinc-map";
import {
type FhirBuildOptions,
type FhirPatientIdentity,
PATIENT_RESOURCE_ID,
patientResource,
coverageResource,
observationsFromReportData,
cycleObservationsFromReportData,
conditionsFromReportData,
medicationStatementsFromReportData,
medicationAdministrationsFromReportData,
} from "@/lib/fhir/resources";
import type {
FhirBundle,
FhirBundleEntry,
FhirComposition,
FhirDiagnosticReport,
FhirReference,
FhirResource,
} from "@/lib/fhir/types";
// Re-export the shared coding constants + option types so existing importers
// (capabilities + health-record routes) keep their `@/lib/fhir/build-bundle`
// import path. The single source of truth now lives in `./resources`.
export {
ATC_SYSTEM,
SNOMED_SYSTEM,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/resources";
export type { FhirBuildOptions, FhirPatientIdentity };
/** Escape the five XML-significant characters for the xhtml narrative. */
function escapeXml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* v1.25 (W-RECORDS) — the structured records folded into the document bundle.
* Passed separately from `DoctorReportData` (they are always-available
* reference records, not part of the time-windowed report aggregation).
* Empty arrays emit nothing.
*/
export interface FhirRecordInputs {
allergies?: AllergyDTO[];
familyHistory?: FamilyHistoryEntryDTO[];
}
/**
* Build a FHIR R4 document Bundle from the aggregated report data.
*
* `now` is injectable for deterministic tests.
*/
export function buildFhirDocumentBundle(
data: DoctorReportData,
identity: FhirPatientIdentity,
now: Date = new Date(),
options: FhirBuildOptions = {},
records: FhirRecordInputs = {},
): FhirBundle {
const patientRef: FhirReference = {
reference: `Patient/${PATIENT_RESOURCE_ID}`,
};
const entries: FhirBundleEntry[] = [];
const observationRefs: FhirReference[] = [];
const cycleObservationRefs: FhirReference[] = [];
const medicationRefs: FhirReference[] = [];
const administrationRefs: FhirReference[] = [];
const conditionRefs: FhirReference[] = [];
const allergyRefs: FhirReference[] = [];
const familyHistoryRefs: FhirReference[] = [];
// --- Patient -----------------------------------------------------------
const patient = patientResource(data, identity);
entries.push({ fullUrl: `urn:uuid:${patient.id}`, resource: patient });
// --- Coverage (insurer; sits right after the Patient) ------------------
const coverage = coverageResource(data, identity);
if (coverage) {
entries.push({ fullUrl: `urn:uuid:${coverage.id}`, resource: coverage });
}
// --- Observations (vital / activity / lab / survey, in canonical order)-
for (const obs of observationsFromReportData(data, identity, options)) {
entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
observationRefs.push({ reference: `Observation/${obs.id}` });
}
// --- Cycle / reproductive-health Observations (opt-in only) -----------
// v1.15.0 — grouped into their own "Menstrual cycle" Composition section
// so a reproductive-health finding is never mixed into the vital-signs
// panel. Absent unless the user toggled the cycle section AND has data.
for (const obs of cycleObservationsFromReportData(data)) {
entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
cycleObservationRefs.push({ reference: `Observation/${obs.id}` });
}
// --- MedicationStatement per active medication -------------------------
for (const stmt of medicationStatementsFromReportData(data, options)) {
entries.push({ fullUrl: `urn:uuid:${stmt.id}`, resource: stmt });
medicationRefs.push({ reference: `MedicationStatement/${stmt.id}` });
}
// --- MedicationAdministration per acted intake -------------------------
for (const admin of medicationAdministrationsFromReportData(data, options)) {
entries.push({ fullUrl: `urn:uuid:${admin.id}`, resource: admin });
administrationRefs.push({
reference: `MedicationAdministration/${admin.id}`,
});
}
// --- Condition + Encounter per illness/condition episode (opt-in) ------
// v1.18.1 — present only when the illness module is enabled AND the window
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
// episode emits a patient-reported Condition (generic SNOMED root, label on
// `code.text`) plus a bounding Encounter that references it.
const { conditions, encounters } = conditionsFromReportData(data);
for (const condition of conditions) {
entries.push({ fullUrl: `urn:uuid:${condition.id}`, resource: condition });
conditionRefs.push({ reference: `Condition/${condition.id}` });
}
for (const encounter of encounters) {
entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter });
}
// --- AllergyIntolerance per recorded allergy (v1.25) -------------------
// Always-available structured records (not time-windowed). Patient-reported
// (verificationStatus unconfirmed); the substance rides `code.text`.
for (const allergy of allergyIntoleranceResources(records.allergies ?? [])) {
entries.push({ fullUrl: `urn:uuid:${allergy.id}`, resource: allergy });
allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` });
}
// --- FamilyMemberHistory per recorded entry (v1.25) -------------------
for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) {
entries.push({ fullUrl: `urn:uuid:${fmh.id}`, resource: fmh });
familyHistoryRefs.push({ reference: `FamilyMemberHistory/${fmh.id}` });
}
// --- Composition (leading "cover" resource) ----------------------------
// v1.9.0 — when the aggregator capped the administration set, disclose
// it in the narrative so the export is honest: it carries the
// most-recent N of M acted intakes, the oldest having been omitted.
const displayName = data.patient.fullName ?? data.patient.username ?? null;
const truncation = data.medicationAdministrationsTruncation;
const narrativeText = [
`Health record for ${escapeXml(displayName ?? "patient")}.`,
`Reporting period ${data.period.start.slice(0, 10)} to ${data.period.end.slice(0, 10)}.`,
`${observationRefs.length} observation(s), ${medicationRefs.length} medication(s), ${administrationRefs.length} administration(s).`,
...(truncation
? [
`Medication administrations truncated: showing the most recent ${truncation.included} of ${truncation.total} recorded; older entries omitted.`,
]
: []),
].join(" ");
const composition: FhirComposition = {
resourceType: "Composition",
id: "composition-1",
status: "final",
type: {
coding: [
{
system: LOINC_SYSTEM,
code: "11503-0",
display: "Medical records",
},
],
text: "Health record",
},
subject: patientRef,
date: now.toISOString(),
author: [{ reference: "Device/healthlog", display: "HealthLog" }],
title: "Health Record",
// v1.9.0 — top-level document narrative (Coverage nit 2). Reuses the
// same escaped plain-text summary the Vital-signs section carries, so
// a strict US-Core-style validator sees a `Composition.text`.
text: {
status: "generated",
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
},
section: [
{
// Vital-signs section carries the narrative + every Observation ref,
// matching the iOS Bundle graph ("Vital signs" + "Medications").
title: "Vital signs",
text: {
status: "generated",
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
},
entry: [patientRef, ...observationRefs],
},
// Medications section carries both the active-medication
// statements and the per-dose administration records (v1.9.0), so
// the iOS two-section graph ("Vital signs" + "Medications") is
// preserved without adding a third section.
...(medicationRefs.length > 0 || administrationRefs.length > 0
? [
{
title: "Medications",
entry: [...medicationRefs, ...administrationRefs],
},
]
: []),
// v1.15.0 — opt-in reproductive-health section. Only present when the
// cycle toggle surfaced cycle Observations.
...(cycleObservationRefs.length > 0
? [
{
title: "Menstrual cycle",
entry: cycleObservationRefs,
},
]
: []),
// v1.18.1 — opt-in illness/condition section. Only present when the
// illness module surfaced episodes. Lists the Condition resources (the
// Encounter rides each Condition's reasonReference).
...(conditionRefs.length > 0
? [
{
title: "Conditions",
entry: conditionRefs,
},
]
: []),
// v1.25 — structured allergy records (AllergyIntolerance). Present only
// when the account recorded at least one allergy.
...(allergyRefs.length > 0
? [
{
title: "Allergies",
entry: allergyRefs,
},
]
: []),
// v1.25 — structured family-history records (FamilyMemberHistory).
...(familyHistoryRefs.length > 0
? [
{
title: "Family history",
entry: familyHistoryRefs,
},
]
: []),
],
};
// --- DiagnosticReport (vital-signs panel; routes all Observations) -----
// Last entry in the Bundle, matching the iOS Bundle graph. `result[]`
// references every emitted Observation.
const diagnosticReport: FhirDiagnosticReport = {
resourceType: "DiagnosticReport",
id: "diagnostic-report-1",
status: "final",
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "85353-1",
display:
"Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
},
],
text: "Vital signs panel",
},
subject: patientRef,
effectivePeriod: { start: data.period.start, end: data.period.end },
result: observationRefs,
};
// The Composition must be the FIRST entry in a document Bundle; the
// DiagnosticReport is the LAST.
const orderedEntries: FhirBundleEntry[] = [
{
fullUrl: `urn:uuid:${composition.id}`,
resource: composition as FhirResource,
},
...entries,
{
fullUrl: `urn:uuid:${diagnosticReport.id}`,
resource: diagnosticReport as FhirResource,
},
];
return {
resourceType: "Bundle",
type: "document",
timestamp: now.toISOString(),
entry: orderedEntries,
};
}