mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
490 lines
18 KiB
TypeScript
490 lines
18 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 `Device` that authored it, the `Patient`,
|
|
* then one `Observation` per selected vital / glucose context / lab / mood,
|
|
* one `MedicationStatement` per active medication, and the structured records.
|
|
*
|
|
* 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).
|
|
*
|
|
* The per-resource emitters live in `./resources`, shared with the FHIR REST
|
|
* routes; this builder composes them into the document Bundle, mints the entry
|
|
* identities, and rewrites every internal reference onto them.
|
|
*
|
|
* 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 { randomUUID } from "node:crypto";
|
|
|
|
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,
|
|
anamnesisNarrativeFromReportData,
|
|
anamnesisObservationsFromReportData,
|
|
conditionsFromReportData,
|
|
medicationStatementsFromReportData,
|
|
medicationAdministrationsFromReportData,
|
|
} from "@/lib/fhir/resources";
|
|
import type {
|
|
FhirBundle,
|
|
FhirBundleEntry,
|
|
FhirComposition,
|
|
FhirDevice,
|
|
FhirDiagnosticReport,
|
|
FhirObservation,
|
|
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 lives in `./resources`.
|
|
export {
|
|
ATC_SYSTEM,
|
|
SNOMED_SYSTEM,
|
|
GERMAN_ATC_DEFAULT_LOCALES,
|
|
} from "@/lib/fhir/resources";
|
|
export type { FhirBuildOptions, FhirPatientIdentity };
|
|
|
|
/** Namespace for the document's own identifier (`bdl-9`). */
|
|
const BUNDLE_IDENTIFIER_SYSTEM = "https://healthlog.dev/fhir/bundle-id";
|
|
|
|
/** Resource id of the authoring `Device` the Composition names. */
|
|
const AUTHOR_DEVICE_ID = "healthlog";
|
|
|
|
/** 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[];
|
|
}
|
|
|
|
/**
|
|
* The running build's version, for the authoring Device. Same resolution order
|
|
* as `/api/version`: the image build arg wins so the exported document names
|
|
* the release that produced it, with the package version as the local-dev
|
|
* fallback.
|
|
*/
|
|
function runningVersion(): string {
|
|
return (
|
|
process.env.NEXT_PUBLIC_APP_VERSION?.trim() ||
|
|
process.env.npm_package_version?.trim() ||
|
|
"unknown"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return a copy of `value` in which every relative reference
|
|
* (`Observation/obs-1`) names the entry identity that actually carries the
|
|
* resource.
|
|
*
|
|
* `bdl-7` resolves an intra-document reference against `Bundle.entry.fullUrl`,
|
|
* and the fullUrls are URNs — so a relative reference resolves against the
|
|
* base of the RETRIEVING server, i.e. somewhere else entirely, or nowhere. One
|
|
* total walk over the assembled resources is what makes that impossible to get
|
|
* partially right: an emitter that grows a new reference is covered the day it
|
|
* lands, without a second place to remember.
|
|
*
|
|
* Copies rather than mutates: the emitters share module-level reference
|
|
* constants (one `{ reference: "Patient/patient-1" }` object is the `subject`
|
|
* of every resource in the tree), and rewriting in place would rewrite them
|
|
* for every later build in the process.
|
|
*
|
|
* Contained `#`-refs (the Coverage payor Organization) are left alone — they
|
|
* already resolve inside their own resource.
|
|
*/
|
|
function withResolvedReferences<T>(
|
|
value: T,
|
|
urnByReference: ReadonlyMap<string, string>,
|
|
): T {
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) =>
|
|
withResolvedReferences(item, urnByReference),
|
|
) as unknown as T;
|
|
}
|
|
if (value === null || typeof value !== "object") return value;
|
|
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
out[key] =
|
|
key === "reference" && typeof child === "string"
|
|
? (urnByReference.get(child) ?? child)
|
|
: withResolvedReferences(child, urnByReference);
|
|
}
|
|
return out as T;
|
|
}
|
|
|
|
/** True for an Observation the vital-signs panel may legitimately claim. */
|
|
function isVitalSignsObservation(observation: FhirObservation): boolean {
|
|
return (observation.category ?? []).some((concept) =>
|
|
(concept.coding ?? []).some((coding) => coding.code === "vital-signs"),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 deviceRef: FhirReference = {
|
|
reference: `Device/${AUTHOR_DEVICE_ID}`,
|
|
display: "HealthLog",
|
|
};
|
|
const entries: FhirBundleEntry[] = [];
|
|
const observationRefs: FhirReference[] = [];
|
|
const vitalSignsObservationRefs: FhirReference[] = [];
|
|
const cycleObservationRefs: FhirReference[] = [];
|
|
const anamnesisObservationRefs: FhirReference[] = [];
|
|
const medicationRefs: FhirReference[] = [];
|
|
const administrationRefs: FhirReference[] = [];
|
|
const conditionRefs: FhirReference[] = [];
|
|
const encounterRefs: FhirReference[] = [];
|
|
const allergyRefs: FhirReference[] = [];
|
|
const familyHistoryRefs: FhirReference[] = [];
|
|
const coverageRefs: FhirReference[] = [];
|
|
|
|
/**
|
|
* Mint the entry identity. `fullUrl` is a fresh RFC-4122 UUID per entry —
|
|
* `resource.id` stays the human-readable `obs-3` a maintainer can follow
|
|
* through a diff, while the identity a receiver resolves against is unique
|
|
* across every document ever exported.
|
|
*/
|
|
const push = (resource: FhirResource): void => {
|
|
entries.push({ fullUrl: `urn:uuid:${randomUUID()}`, resource });
|
|
};
|
|
|
|
// --- Device (the Composition's author) ---------------------------------
|
|
const device: FhirDevice = {
|
|
resourceType: "Device",
|
|
id: AUTHOR_DEVICE_ID,
|
|
manufacturer: "HealthLog",
|
|
deviceName: [{ name: "HealthLog", type: "manufacturer-name" }],
|
|
version: [{ value: runningVersion() }],
|
|
};
|
|
push(device);
|
|
|
|
// --- Patient -----------------------------------------------------------
|
|
push(patientResource(data, identity));
|
|
|
|
// --- Coverage (insurer; sits right after the Patient) ------------------
|
|
const coverage = coverageResource(data, identity);
|
|
if (coverage) {
|
|
push(coverage);
|
|
coverageRefs.push({ reference: `Coverage/${coverage.id}` });
|
|
}
|
|
|
|
// --- Observations (vital / activity / lab / survey, in canonical order)-
|
|
for (const obs of observationsFromReportData(data)) {
|
|
push(obs);
|
|
const ref: FhirReference = { reference: `Observation/${obs.id}` };
|
|
observationRefs.push(ref);
|
|
if (isVitalSignsObservation(obs)) vitalSignsObservationRefs.push(ref);
|
|
}
|
|
|
|
// --- 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)) {
|
|
push(obs);
|
|
cycleObservationRefs.push({ reference: `Observation/${obs.id}` });
|
|
}
|
|
|
|
// --- Selected current anamnesis facts (opt-in only) --------------------
|
|
// The collector returns `null` when ANAMNESIS was not selected. When it was
|
|
// selected, all three facts emit even if their value is absent or unreadable
|
|
// so omission cannot be mistaken for consent-based exclusion.
|
|
for (const obs of anamnesisObservationsFromReportData(data)) {
|
|
push(obs);
|
|
anamnesisObservationRefs.push({
|
|
reference: `Observation/${obs.id}`,
|
|
});
|
|
}
|
|
const anamnesisNarrative = anamnesisNarrativeFromReportData(data);
|
|
|
|
// --- MedicationStatement per active medication -------------------------
|
|
for (const stmt of medicationStatementsFromReportData(data, options)) {
|
|
push(stmt);
|
|
medicationRefs.push({ reference: `MedicationStatement/${stmt.id}` });
|
|
}
|
|
|
|
// --- MedicationAdministration per acted intake -------------------------
|
|
for (const admin of medicationAdministrationsFromReportData(data, options)) {
|
|
push(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) {
|
|
push(condition);
|
|
conditionRefs.push({ reference: `Condition/${condition.id}` });
|
|
}
|
|
for (const encounter of encounters) {
|
|
push(encounter);
|
|
encounterRefs.push({ reference: `Encounter/${encounter.id}` });
|
|
}
|
|
|
|
// --- 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 ?? [])) {
|
|
push(allergy);
|
|
allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` });
|
|
}
|
|
|
|
// --- FamilyMemberHistory per recorded entry (v1.25) -------------------
|
|
for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) {
|
|
push(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 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 },
|
|
// A vital-signs panel reports vital signs. Routing labs, adherence rates
|
|
// and survey scores through it would let a receiver read a questionnaire
|
|
// total as a vital sign because the panel code said so.
|
|
result: vitalSignsObservationRefs,
|
|
};
|
|
const diagnosticReportRef: FhirReference = {
|
|
reference: `DiagnosticReport/${diagnosticReport.id}`,
|
|
};
|
|
|
|
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: [deviceRef],
|
|
title: "Health Record",
|
|
// v1.9.0 — top-level document narrative. Section narrative is already
|
|
// present; a strict US-Core-style validator additionally expects a
|
|
// `Composition.text`. Reuses the same escaped plain-text summary.
|
|
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.
|
|
// NOT the Patient: `Composition.subject` above already names the
|
|
// person the whole document is about, and a subject listed as a
|
|
// section entry reads as a finding.
|
|
title: "Vital signs",
|
|
text: {
|
|
status: "generated",
|
|
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
|
|
},
|
|
entry: [...observationRefs, diagnosticReportRef],
|
|
},
|
|
// Medications section carries both the active-medication
|
|
// statements and the per-dose administration records (v1.9.0).
|
|
...(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,
|
|
},
|
|
]
|
|
: []),
|
|
...(anamnesisNarrative !== null
|
|
? [
|
|
{
|
|
title: "Anamnesis",
|
|
code: {
|
|
coding: [
|
|
{
|
|
system: LOINC_SYSTEM,
|
|
code: "11329-0",
|
|
display: "History general Narrative - Reported",
|
|
},
|
|
],
|
|
text: "General history",
|
|
},
|
|
text: {
|
|
status: "generated" as const,
|
|
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(anamnesisNarrative)}</div>`,
|
|
},
|
|
entry: anamnesisObservationRefs,
|
|
},
|
|
]
|
|
: []),
|
|
// v1.18.1 — opt-in illness/condition section. Lists the Condition
|
|
// resources AND the Encounters that bound them: an Encounter reachable
|
|
// only from a Condition's `reasonReference` is not reachable from the
|
|
// document at all.
|
|
...(conditionRefs.length > 0
|
|
? [
|
|
{
|
|
title: "Conditions",
|
|
entry: [...conditionRefs, ...encounterRefs],
|
|
},
|
|
]
|
|
: []),
|
|
// 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,
|
|
},
|
|
]
|
|
: []),
|
|
// The insurance relationship. A Coverage no section reaches is a
|
|
// resource the document carries and never mentions.
|
|
...(coverageRefs.length > 0
|
|
? [
|
|
{
|
|
title: "Insurance",
|
|
entry: coverageRefs,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
|
|
// The Composition must be the FIRST entry in a document Bundle; the
|
|
// DiagnosticReport is the LAST.
|
|
const orderedEntries: FhirBundleEntry[] = [
|
|
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: composition },
|
|
...entries,
|
|
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport },
|
|
];
|
|
|
|
// Every entry identity is known only now, so the reference rewrite runs once
|
|
// over the finished set.
|
|
const urnByReference = new Map<string, string>(
|
|
orderedEntries.map((entry) => [
|
|
`${entry.resource.resourceType}/${entry.resource.id}`,
|
|
entry.fullUrl,
|
|
]),
|
|
);
|
|
const resolvedEntries: FhirBundleEntry[] = orderedEntries.map((entry) => ({
|
|
fullUrl: entry.fullUrl,
|
|
resource: withResolvedReferences(entry.resource, urnByReference),
|
|
}));
|
|
|
|
return {
|
|
resourceType: "Bundle",
|
|
type: "document",
|
|
identifier: {
|
|
system: BUNDLE_IDENTIFIER_SYSTEM,
|
|
value: randomUUID(),
|
|
},
|
|
timestamp: now.toISOString(),
|
|
entry: resolvedEntries,
|
|
};
|
|
}
|