mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
328 lines
12 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|