From 747a62516a868d5967edd6f0cb72d65c4d37685a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 00:40:11 +0200 Subject: [PATCH] refactor(fhir): extract per-resource emitters into resources module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the Observation / MedicationStatement / MedicationAdministration / Patient / Coverage builders — with their LOINC/ATC/SNOMED/UCUM codings and the survey-category wellness split — out of build-bundle.ts into a shared src/lib/fhir/resources.ts. buildFhirDocumentBundle now composes the emitters so the upcoming FHIR REST search routes reuse the exact same coding logic instead of a second copy of the mapping. Pure refactor: the document-bundle output stays byte-identical (the existing FHIR + export tests pass unchanged). The shared coding constants and option types re-export from build-bundle so existing import paths keep working. --- src/lib/fhir/build-bundle.ts | 637 +++----------------------------- src/lib/fhir/resources.ts | 680 +++++++++++++++++++++++++++++++++++ 2 files changed, 725 insertions(+), 592 deletions(-) create mode 100644 src/lib/fhir/resources.ts diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index bc650c642..03db95562 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -12,217 +12,47 @@ * 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 { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose"; +import { LOINC_SYSTEM } from "@/lib/fhir/loinc-map"; import { - LOINC_SYSTEM, - HEALTHKIT_CODESYSTEM, - UCUM_SYSTEM, - MEASUREMENT_LOINC, - BP_PANEL_LOINC, - BP_SYS_LOINC, - BP_DIA_LOINC, - BP_UNIT, - GLUCOSE_LOINC, - MEDICATION_ADHERENCE_LOINC, - MOOD_LOINC, - type LoincMapping, -} from "@/lib/fhir/loinc-map"; + type FhirBuildOptions, + type FhirPatientIdentity, + PATIENT_RESOURCE_ID, + patientResource, + coverageResource, + observationsFromReportData, + medicationStatementsFromReportData, + medicationAdministrationsFromReportData, +} from "@/lib/fhir/resources"; import type { FhirBundle, FhirBundleEntry, - FhirCodeableConcept, - FhirObservation, - FhirMedicationStatement, - FhirMedicationAdministration, - FhirDosage, - FhirPatient, - FhirCoverage, - FhirOrganization, FhirComposition, FhirDiagnosticReport, FhirReference, FhirResource, } from "@/lib/fhir/types"; -/** Patient identity not carried in `DoctorReportData` (KVNR is encrypted). */ -export interface FhirPatientIdentity { - /** German KVNR (decrypted by the route). */ - insuranceNumber: string | null; -} - -/** KVNR identifier namespace per the gematik SID. */ -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. - */ -export const ATC_SYSTEM = "http://www.whocc.no/atc"; -/** - * German national ATC URI maintained by the BfArM. Same ATC classification - * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never - * a replacement) when a German-region export is requested, so the WHO entry - * stays first and byte-identical for every consumer. - */ -const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc"; -const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; -/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */ -export const SNOMED_SYSTEM = "http://snomed.info/sct"; - -/** - * The app locales for which a health-record export defaults `germanAtc` on - * (the additive BfArM ATC coding). The export route derives the flag from - * the user's locale against this set; the capabilities endpoint surfaces it - * verbatim so a client can predict the coding without a round-trip. Keeping - * it here — beside the BfArM URI it gates — makes the two move together. - */ -export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const; - -/** Options threaded from the export route into the builder. Additive, all defaulted. */ -export interface FhirBuildOptions { - /** - * When true, additionally emit the German BfArM ATC URI alongside the WHO - * entry on each medication concept. The WHO coding stays first and - * byte-identical; this only appends a second URI for the same leaf code. - * Defaults off; the route turns it on for a German-region export. - */ - germanAtc?: boolean; -} - -/** - * 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. - * When `germanAtc` is set, the same ATC leaf code is also published under - * the BfArM URI AFTER the WHO entry — additive, never reordering WHO. - */ -function medicationConcept( - name: string, - atcCode: string | null | undefined, - rxNormCode: string | null | undefined, - germanAtc: boolean, -): FhirCodeableConcept { - const coding: NonNullable = []; - if (atcCode) { - coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); - if (germanAtc) { - coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name }); - } - } - if (rxNormCode) { - coding.push({ system: RXNORM_SYSTEM, code: rxNormCode }); - } - return coding.length > 0 ? { coding, text: name } : { text: name }; -} - -/** - * v1.9.0 — UCUM codes for the dose units HealthLog stores. The display - * `unit` is always the user's original string; the UCUM `code` is set - * only for an unambiguous mapping so a consumer that resolves UCUM never - * sees a guessed code. An unmapped unit drops `code` (and `system`) and - * keeps just the human-readable `unit` — conformant, just not coded. - */ -const UCUM_DOSE_CODES: Record = { - mg: "mg", - g: "g", - mcg: "ug", - µg: "ug", - ug: "ug", - ml: "mL", - mL: "mL", -}; - -function doseQuantity(value: number, unit: string): { - value: number; - unit: string; - system?: string; - code?: string; -} { - const ucum = UCUM_DOSE_CODES[unit]; - return ucum - ? { value, unit, system: UCUM_SYSTEM, code: ucum } - : { value, unit }; -} - -/** - * Route of administration derived from the medication's delivery form, - * carrying an additive SNOMED CT `coding` alongside the existing `.text` - * anchor. HealthLog injections are subcutaneous (the injection-site picker - * exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to - * the subcutaneous route. Returns `undefined` for an unknown / absent form - * so no empty route is emitted. - */ -const ROUTE_SNOMED: Record = { - ORAL: { code: "26643006", display: "Oral route" }, - INJECTION: { code: "34206005", display: "Subcutaneous route" }, -}; - -function routeConcept( - deliveryForm: string | null, -): FhirCodeableConcept | undefined { - const text = - deliveryForm === "ORAL" - ? "Oral" - : deliveryForm === "INJECTION" - ? "Injection" - : undefined; - if (!text) return undefined; - const snomed = ROUTE_SNOMED[deliveryForm as string]; - return snomed - ? { - coding: [ - { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, - ], - text, - } - : { text }; -} - -/** - * Administration body-site keyed on the raw `InjectionSite` enum value, - * carrying an additive SNOMED CT body-region `coding` alongside the `.text` - * anchor. The map collapses the eight enum members to three gross body-region - * concepts (abdomen / thigh / upper arm); laterality (left/right) and the - * abdominal quadrant are NOT lateralised SNOMED concepts here — they are - * preserved verbatim in the human-readable `.text` (the raw enum value), so - * no information is lost. - */ -const SITE_SNOMED: Record = { - ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" }, - THIGH_LEFT: { code: "68367000", display: "Thigh structure" }, - THIGH_RIGHT: { code: "68367000", display: "Thigh structure" }, - UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" }, - UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" }, -}; - -function siteConcept(injectionSite: string): FhirCodeableConcept { - const snomed = SITE_SNOMED[injectionSite]; - // Preserve the full enum value (incl. laterality) as the readable anchor. - const text = injectionSite; - return snomed - ? { - coding: [ - { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, - ], - text, - } - : { text }; -} +// 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 { @@ -234,53 +64,6 @@ function escapeXml(value: string): string { .replace(/'/g, "'"); } -function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { - if (m.loinc) { - // HealthKit placeholder codes have no published LOINC term; they must not - // sit under the LOINC namespace (a non-LOINC code there is a conformance - // violation). Route them onto the shared custom CodeSystem instead — - // byte-aligned with the iOS exporter. - const system = m.loinc.startsWith("HKQuantityTypeIdentifier") - ? HEALTHKIT_CODESYSTEM - : LOINC_SYSTEM; - return { - coding: [{ system, code: m.loinc, display: m.display }], - text: m.display, - }; - } - // No stable LOINC — local text-only concept (documented fallback). - return { text: m.display }; -} - -/** v1.10.0 — English display per persisted wellness-score type (FHIR - * text-only concept; the score has no published LOINC term). */ -const WELLNESS_SCORE_DISPLAY: Record = { - RECOVERY_SCORE: "Recovery score", - STRESS_SCORE: "Stress score", - STRAIN_SCORE: "Strain score", -}; - -function categoryConcept(category: string): FhirCodeableConcept { - return { - coding: [ - { - system: "http://terminology.hl7.org/CodeSystem/observation-category", - code: category, - }, - ], - }; -} - -/** Latest `{ value, measuredAt }` for a type, or null when no rows. */ -function latestReading( - data: DoctorReportData, - type: string, -): { value: number; measuredAt: string } | null { - const series = data.measurements[type]; - if (!series || series.length === 0) return null; - return series[series.length - 1]; -} - /** * Build a FHIR R4 document Bundle from the aggregated report data. * @@ -292,379 +75,49 @@ export function buildFhirDocumentBundle( now: Date = new Date(), options: FhirBuildOptions = {}, ): FhirBundle { - const germanAtc = options.germanAtc ?? false; - const patientId = "patient-1"; - const patientRef: FhirReference = { reference: `Patient/${patientId}` }; + const patientRef: FhirReference = { + reference: `Patient/${PATIENT_RESOURCE_ID}`, + }; const entries: FhirBundleEntry[] = []; const observationRefs: FhirReference[] = []; const medicationRefs: FhirReference[] = []; + const administrationRefs: FhirReference[] = []; // --- Patient ----------------------------------------------------------- - const patient: FhirPatient = { - resourceType: "Patient", - id: patientId, - }; - const displayName = data.patient.fullName ?? data.patient.username ?? null; - if (displayName) patient.name = [{ text: displayName }]; - if (data.patient.gender) { - patient.gender = data.patient.gender === "MALE" ? "male" : "female"; - } - if (data.patient.dateOfBirth) { - patient.birthDate = data.patient.dateOfBirth.slice(0, 10); - } - if (identity.insuranceNumber) { - patient.identifier = [ - { system: KVNR_SYSTEM, value: identity.insuranceNumber }, - ]; - } - entries.push({ fullUrl: `urn:uuid:${patientId}`, resource: patient }); + const patient = patientResource(data, identity); + entries.push({ fullUrl: `urn:uuid:${patient.id}`, resource: patient }); // --- Coverage (insurer; sits right after the Patient) ------------------ - // v1.9.0 — emitted whenever ANY payor signal is present: an insurer - // name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server - // with the iOS exporter (which emits a Coverage on a bare member id); - // it carries the `subscriberId` with no contained payor Organization. - // When an insurer name and/or IKNR is known, the payor is a CONTAINED - // Organization referenced by a local `#`-ref. The KVNR stays on - // `Patient.identifier` and doubles as the `subscriberId` (member id). - const insurerName = data.patient.insurerName ?? null; - const insurerIkNumber = data.patient.insurerIkNumber ?? null; - const hasPayorOrg = Boolean(insurerName || insurerIkNumber); - if (hasPayorOrg || identity.insuranceNumber) { - const coverage: FhirCoverage = { - resourceType: "Coverage", - id: "coverage-1", - status: "active", - beneficiary: patientRef, - }; - if (hasPayorOrg) { - const orgId = "insurer-org-1"; - const payorOrg: FhirOrganization = { - resourceType: "Organization", - id: orgId, - }; - if (insurerIkNumber) { - payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }]; - } - if (insurerName) payorOrg.name = insurerName; - coverage.contained = [payorOrg]; - coverage.payor = [{ reference: `#${orgId}` }]; - } - if (identity.insuranceNumber) { - coverage.subscriberId = identity.insuranceNumber; - } - entries.push({ - fullUrl: `urn:uuid:${coverage.id}`, - resource: coverage, - }); + const coverage = coverageResource(data, identity); + if (coverage) { + entries.push({ fullUrl: `urn:uuid:${coverage.id}`, resource: coverage }); } - let obsSeq = 0; - const pushObservation = (obs: FhirObservation) => { + // --- 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}` }); - }; - - // --- Vital-sign + activity Observations (one latest reading per type) -- - const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); - - for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) { - // BMI is emitted once by the computed-BMI block below (matching the PDF's - // BMI line); skip the stored BODY_MASS_INDEX series here to avoid a - // duplicate Observation. - if (type === "BODY_MASS_INDEX") continue; - const reading = latestReading(data, type); - if (!reading) continue; - // SLEEP_DURATION is stored in MINUTES; the iOS-locked UCUM unit is `h`, so - // emit the value in hours to keep value and unit consistent. The PDF reads - // the raw series independently and is unaffected. - const value = - type === "SLEEP_DURATION" - ? Math.round((reading.value / 60) * 100) / 100 - : reading.value; - obsSeq += 1; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept(mapping.category)], - code: codeableFromMapping(mapping), - subject: patientRef, - effectiveDateTime: reading.measuredAt, - valueQuantity: { - value, - unit: mapping.unit, - system: UCUM_SYSTEM, - code: mapping.unit, - }, - }); - } - - // --- Blood-pressure panel (sys + dia components) ----------------------- - const sys = latestReading(data, "BLOOD_PRESSURE_SYS"); - const dia = latestReading(data, "BLOOD_PRESSURE_DIA"); - if (sys && dia) { - obsSeq += 1; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept("vital-signs")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: BP_PANEL_LOINC, - display: "Blood pressure panel", - }, - ], - text: "Blood pressure", - }, - subject: patientRef, - // Use the systolic reading's timestamp as the panel effective time. - effectiveDateTime: sys.measuredAt, - component: [ - { - code: { - coding: [ - { system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" }, - ], - }, - valueQuantity: { - value: sys.value, - unit: BP_UNIT, - system: UCUM_SYSTEM, - code: BP_UNIT, - }, - }, - { - code: { - coding: [ - { system: LOINC_SYSTEM, code: BP_DIA_LOINC, display: "Diastolic" }, - ], - }, - valueQuantity: { - value: dia.value, - unit: BP_UNIT, - system: UCUM_SYSTEM, - code: BP_UNIT, - }, - }, - ], - }); - } - - // --- Computed BMI Observation (matches the PDF's BMI line) ------------- - if (data.bmi !== null && data.bmi !== undefined) { - obsSeq += 1; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept("vital-signs")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: "39156-5", - display: "Body mass index (BMI) [Ratio]", - }, - ], - text: "Body mass index (BMI) [Ratio]", - }, - subject: patientRef, - effectiveDateTime: data.period.end, - valueQuantity: { - value: data.bmi, - unit: "kg/m2", - system: UCUM_SYSTEM, - code: "kg/m2", - }, - }); - } - - // --- Glucose Observations (per context, user display unit) ------------- - for (const [ctx, stat] of Object.entries(data.glucoseStats)) { - const map = GLUCOSE_LOINC[ctx]; - if (!map) continue; - obsSeq += 1; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept("laboratory")], - code: { - coding: [{ system: LOINC_SYSTEM, code: map.loinc, display: map.display }], - text: map.display, - }, - subject: patientRef, - effectiveDateTime: data.period.end, - valueQuantity: { - value: convertGlucose(stat.latest, glucoseUnit), - unit: glucoseUnit, - system: UCUM_SYSTEM, - code: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL", - }, - }); - } - - // --- Medication-adherence Observations (one per medication) ------------ - for (const [name, comp] of Object.entries(data.compliance)) { - if (comp.total <= 0) continue; - obsSeq += 1; - const rate = Math.round((comp.taken / comp.total) * 1000) / 10; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept("activity")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: MEDICATION_ADHERENCE_LOINC, - display: "Medication adherence", - }, - ], - text: `Medication adherence — ${name}`, - }, - subject: patientRef, - effectiveDateTime: data.period.end, - valueQuantity: { value: rate, unit: "%", system: UCUM_SYSTEM, code: "%" }, - }); - } - - // --- Mood Observation (opt-in only; absent when toggle off) ------------ - if (data.mood) { - obsSeq += 1; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept("vital-signs")], - code: { - coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }], - text: "Mood (average over period)", - }, - subject: patientRef, - effectiveDateTime: data.period.end, - valueQuantity: { - value: Math.round(data.mood.avg * 10) / 10, - unit: "{score}", - system: UCUM_SYSTEM, - code: "{score}", - }, - }); - } - - // --- Wellness-score Observations (descriptive composites) -------------- - // v1.10.0 — the server-derived nightly scores (recovery / stress / - // strain). They have no published LOINC term and are NOT clinical - // findings, so each is emitted under the `survey` category with a - // text-only concept and an explicit "descriptive, not a clinical - // assessment" note — a physician's FHIR viewer never mistakes a band for - // a diagnosis. Absent when the aggregator emitted no scores. - if (data.wellnessScores && data.wellnessScores.length > 0) { - for (const s of data.wellnessScores) { - obsSeq += 1; - pushObservation({ - resourceType: "Observation", - id: `obs-${obsSeq}`, - status: "final", - category: [categoryConcept("survey")], - code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" }, - subject: patientRef, - effectiveDateTime: s.latestAt, - valueQuantity: { - value: s.latest, - unit: "{score}", - system: UCUM_SYSTEM, - code: "{score}", - }, - note: [ - { - text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.", - }, - ], - }); - } } // --- MedicationStatement per active medication ------------------------- - let medSeq = 0; - for (const med of data.medications) { - medSeq += 1; - const id = `med-${medSeq}`; - const stmt: FhirMedicationStatement = { - resourceType: "MedicationStatement", - id, - status: "active", - // 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, - germanAtc, - ), - subject: patientRef, - }; - if (med.dose) stmt.dosage = [{ text: med.dose }]; - entries.push({ fullUrl: `urn:uuid:${id}`, resource: stmt }); - medicationRefs.push({ reference: `MedicationStatement/${id}` }); + 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 ------------------------- - // One resource per intake the user actually actioned: `completed` - // (taken) or `not-done` (explicitly skipped). Pending / missed slots and - // soft-deleted tombstones are excluded upstream by the aggregator. The - // concept reuses the same ATC/RxNorm coding as the statement so each - // administration is self-describing without resolving a reference (no - // `partOf` / `request` coupling — see the v1.9.0 design doc). A `dosage` - // is emitted ONLY when a structured `dose` Quantity is available; a - // dosage with only `.text` would violate the R4 dose-or-rate invariant. - const administrationRefs: FhirReference[] = []; - let adminSeq = 0; - for (const admin of data.medicationAdministrations ?? []) { - adminSeq += 1; - const id = `medadmin-${adminSeq}`; - const resource: FhirMedicationAdministration = { - resourceType: "MedicationAdministration", - id, - status: admin.status, - medicationCodeableConcept: medicationConcept( - admin.medicationName, - admin.atcCode, - admin.rxNormCode, - germanAtc, - ), - subject: patientRef, - effectiveDateTime: admin.effectiveAt, - }; - - // Dosage: only when a structured dose Quantity exists. Carry the - // free-text dose + route + site alongside it. Route and site each carry - // an additive SNOMED coding plus the existing `.text` anchor. - if (admin.dose) { - const dosage: FhirDosage = { - dose: doseQuantity(admin.dose.value, admin.dose.unit), - }; - if (admin.doseText) dosage.text = admin.doseText; - const route = routeConcept(admin.deliveryForm); - if (route) dosage.route = route; - if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite); - resource.dosage = dosage; - } - - entries.push({ fullUrl: `urn:uuid:${id}`, resource }); - administrationRefs.push({ reference: `MedicationAdministration/${id}` }); + for (const admin of medicationAdministrationsFromReportData(data, options)) { + entries.push({ fullUrl: `urn:uuid:${admin.id}`, resource: admin }); + administrationRefs.push({ + reference: `MedicationAdministration/${admin.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")}.`, diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts new file mode 100644 index 000000000..b20972a33 --- /dev/null +++ b/src/lib/fhir/resources.ts @@ -0,0 +1,680 @@ +/** + * v1.11.0 — shared per-resource FHIR R4 emitters. + * + * The Observation / MedicationStatement / MedicationAdministration / Patient / + * Coverage builders (with their LOINC/ATC/SNOMED/UCUM codings + the `survey` + * wellness split) live here as small pure functions over the SAME + * `DoctorReportData` the document-bundle builder consumes. + * + * Two callers share these emitters: `buildFhirDocumentBundle` composes them + * into a `type: "document"` Bundle, and the FHIR REST search routes wrap them + * in a `type: "searchset"` Bundle. Keeping the coding logic in one place means + * the LOINC/UCUM/ATC mapping has exactly one home — the document export and + * the REST face can never drift apart. + * + * No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only + * (`./types`), matching the project's "hand-rolled over the documented wire" + * convention. All text is escaped plain text; never user-supplied HTML + * (no markdown library, no `dangerouslySetInnerHTML`). + */ +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose"; +import { + LOINC_SYSTEM, + HEALTHKIT_CODESYSTEM, + UCUM_SYSTEM, + MEASUREMENT_LOINC, + BP_PANEL_LOINC, + BP_SYS_LOINC, + BP_DIA_LOINC, + BP_UNIT, + GLUCOSE_LOINC, + MEDICATION_ADHERENCE_LOINC, + MOOD_LOINC, + type LoincMapping, +} from "@/lib/fhir/loinc-map"; +import type { + FhirCodeableConcept, + FhirObservation, + FhirMedicationStatement, + FhirMedicationAdministration, + FhirDosage, + FhirPatient, + FhirCoverage, + FhirOrganization, + FhirReference, +} from "@/lib/fhir/types"; + +/** Patient identity not carried in `DoctorReportData` (KVNR is encrypted). */ +export interface FhirPatientIdentity { + /** German KVNR (decrypted by the route). */ + insuranceNumber: string | null; +} + +/** KVNR identifier namespace per the gematik SID. */ +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. + */ +export const ATC_SYSTEM = "http://www.whocc.no/atc"; +/** + * German national ATC URI maintained by the BfArM. Same ATC classification + * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never + * a replacement) when a German-region export is requested, so the WHO entry + * stays first and byte-identical for every consumer. + */ +const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc"; +const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; +/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */ +export const SNOMED_SYSTEM = "http://snomed.info/sct"; + +/** + * The app locales for which a health-record export defaults `germanAtc` on + * (the additive BfArM ATC coding). The export route derives the flag from + * the user's locale against this set; the capabilities endpoint surfaces it + * verbatim so a client can predict the coding without a round-trip. Keeping + * it here — beside the BfArM URI it gates — makes the two move together. + */ +export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const; + +/** Options threaded from the export route into the emitters. Additive, all defaulted. */ +export interface FhirBuildOptions { + /** + * When true, additionally emit the German BfArM ATC URI alongside the WHO + * entry on each medication concept. The WHO coding stays first and + * byte-identical; this only appends a second URI for the same leaf code. + * Defaults off; the route turns it on for a German-region export. + */ + germanAtc?: boolean; +} + +/** + * 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. + * When `germanAtc` is set, the same ATC leaf code is also published under + * the BfArM URI AFTER the WHO entry — additive, never reordering WHO. + */ +function medicationConcept( + name: string, + atcCode: string | null | undefined, + rxNormCode: string | null | undefined, + germanAtc: boolean, +): FhirCodeableConcept { + const coding: NonNullable = []; + if (atcCode) { + coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + if (germanAtc) { + coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name }); + } + } + if (rxNormCode) { + coding.push({ system: RXNORM_SYSTEM, code: rxNormCode }); + } + return coding.length > 0 ? { coding, text: name } : { text: name }; +} + +/** + * v1.9.0 — UCUM codes for the dose units HealthLog stores. The display + * `unit` is always the user's original string; the UCUM `code` is set + * only for an unambiguous mapping so a consumer that resolves UCUM never + * sees a guessed code. An unmapped unit drops `code` (and `system`) and + * keeps just the human-readable `unit` — conformant, just not coded. + */ +const UCUM_DOSE_CODES: Record = { + mg: "mg", + g: "g", + mcg: "ug", + µg: "ug", + ug: "ug", + ml: "mL", + mL: "mL", +}; + +function doseQuantity(value: number, unit: string): { + value: number; + unit: string; + system?: string; + code?: string; +} { + const ucum = UCUM_DOSE_CODES[unit]; + return ucum + ? { value, unit, system: UCUM_SYSTEM, code: ucum } + : { value, unit }; +} + +/** + * Route of administration derived from the medication's delivery form, + * carrying an additive SNOMED CT `coding` alongside the existing `.text` + * anchor. HealthLog injections are subcutaneous (the injection-site picker + * exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to + * the subcutaneous route. Returns `undefined` for an unknown / absent form + * so no empty route is emitted. + */ +const ROUTE_SNOMED: Record = { + ORAL: { code: "26643006", display: "Oral route" }, + INJECTION: { code: "34206005", display: "Subcutaneous route" }, +}; + +function routeConcept( + deliveryForm: string | null, +): FhirCodeableConcept | undefined { + const text = + deliveryForm === "ORAL" + ? "Oral" + : deliveryForm === "INJECTION" + ? "Injection" + : undefined; + if (!text) return undefined; + const snomed = ROUTE_SNOMED[deliveryForm as string]; + return snomed + ? { + coding: [ + { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, + ], + text, + } + : { text }; +} + +/** + * Administration body-site keyed on the raw `InjectionSite` enum value, + * carrying an additive SNOMED CT body-region `coding` alongside the `.text` + * anchor. The map collapses the eight enum members to three gross body-region + * concepts (abdomen / thigh / upper arm); laterality (left/right) and the + * abdominal quadrant are NOT lateralised SNOMED concepts here — they are + * preserved verbatim in the human-readable `.text` (the raw enum value), so + * no information is lost. + */ +const SITE_SNOMED: Record = { + ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" }, + THIGH_LEFT: { code: "68367000", display: "Thigh structure" }, + THIGH_RIGHT: { code: "68367000", display: "Thigh structure" }, + UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" }, + UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" }, +}; + +function siteConcept(injectionSite: string): FhirCodeableConcept { + const snomed = SITE_SNOMED[injectionSite]; + // Preserve the full enum value (incl. laterality) as the readable anchor. + const text = injectionSite; + return snomed + ? { + coding: [ + { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, + ], + text, + } + : { text }; +} + +function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { + if (m.loinc) { + // HealthKit placeholder codes have no published LOINC term; they must not + // sit under the LOINC namespace (a non-LOINC code there is a conformance + // violation). Route them onto the shared custom CodeSystem instead — + // byte-aligned with the iOS exporter. + const system = m.loinc.startsWith("HKQuantityTypeIdentifier") + ? HEALTHKIT_CODESYSTEM + : LOINC_SYSTEM; + return { + coding: [{ system, code: m.loinc, display: m.display }], + text: m.display, + }; + } + // No stable LOINC — local text-only concept (documented fallback). + return { text: m.display }; +} + +/** v1.10.0 — English display per persisted wellness-score type (FHIR + * text-only concept; the score has no published LOINC term). */ +const WELLNESS_SCORE_DISPLAY: Record = { + RECOVERY_SCORE: "Recovery score", + STRESS_SCORE: "Stress score", + STRAIN_SCORE: "Strain score", +}; + +function categoryConcept(category: string): FhirCodeableConcept { + return { + coding: [ + { + system: "http://terminology.hl7.org/CodeSystem/observation-category", + code: category, + }, + ], + }; +} + +/** Latest `{ value, measuredAt }` for a type, or null when no rows. */ +function latestReading( + data: DoctorReportData, + type: string, +): { value: number; measuredAt: string } | null { + const series = data.measurements[type]; + if (!series || series.length === 0) return null; + return series[series.length - 1]; +} + +/** Local-`#`-ref / patient anchor id shared by every subject reference. */ +export const PATIENT_RESOURCE_ID = "patient-1"; +const patientRef: FhirReference = { + reference: `Patient/${PATIENT_RESOURCE_ID}`, +}; + +/** + * Emit the `Patient` resource from the aggregated report data + decrypted + * identity. Carries display name, gender, birth date and the KVNR identifier + * when present; every absent field collapses its slot. + */ +export function patientResource( + data: DoctorReportData, + identity: FhirPatientIdentity, +): FhirPatient { + const patient: FhirPatient = { + resourceType: "Patient", + id: PATIENT_RESOURCE_ID, + }; + const displayName = data.patient.fullName ?? data.patient.username ?? null; + if (displayName) patient.name = [{ text: displayName }]; + if (data.patient.gender) { + patient.gender = data.patient.gender === "MALE" ? "male" : "female"; + } + if (data.patient.dateOfBirth) { + patient.birthDate = data.patient.dateOfBirth.slice(0, 10); + } + if (identity.insuranceNumber) { + patient.identifier = [ + { system: KVNR_SYSTEM, value: identity.insuranceNumber }, + ]; + } + return patient; +} + +/** + * Emit the `Coverage` resource, or `null` when there is no payor signal at + * all. v1.9.0 — emitted whenever ANY payor signal is present: an insurer + * name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server with + * the iOS exporter (which emits a Coverage on a bare member id); it carries + * the `subscriberId` with no contained payor Organization. When an insurer + * name and/or IKNR is known, the payor is a CONTAINED Organization referenced + * by a local `#`-ref. The KVNR stays on `Patient.identifier` and doubles as + * the `subscriberId` (member id). + */ +export function coverageResource( + data: DoctorReportData, + identity: FhirPatientIdentity, +): FhirCoverage | null { + const insurerName = data.patient.insurerName ?? null; + const insurerIkNumber = data.patient.insurerIkNumber ?? null; + const hasPayorOrg = Boolean(insurerName || insurerIkNumber); + if (!hasPayorOrg && !identity.insuranceNumber) return null; + + const coverage: FhirCoverage = { + resourceType: "Coverage", + id: "coverage-1", + status: "active", + beneficiary: patientRef, + }; + if (hasPayorOrg) { + const orgId = "insurer-org-1"; + const payorOrg: FhirOrganization = { + resourceType: "Organization", + id: orgId, + }; + if (insurerIkNumber) { + payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }]; + } + if (insurerName) payorOrg.name = insurerName; + coverage.contained = [payorOrg]; + coverage.payor = [{ reference: `#${orgId}` }]; + } + if (identity.insuranceNumber) { + coverage.subscriberId = identity.insuranceNumber; + } + return coverage; +} + +/** + * Emit every `Observation` from the aggregated report data, in the canonical + * order: one latest reading per measurement type, the blood-pressure panel, + * the computed BMI, glucose per context, medication adherence, the opt-in + * mood average, then the descriptive wellness composites under `survey`. + * + * The `obs-N` ids run as one continuous sequence across all of these so the + * document builder's references stay stable; a `searchset` caller can filter + * the returned array by `category`/`code` without re-numbering. + */ +export function observationsFromReportData( + data: DoctorReportData, + _identity: FhirPatientIdentity, + options: FhirBuildOptions = {}, +): FhirObservation[] { + void options; + const observations: FhirObservation[] = []; + let obsSeq = 0; + const push = (obs: FhirObservation) => observations.push(obs); + + // --- Vital-sign + activity Observations (one latest reading per type) -- + const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); + + for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) { + // BMI is emitted once by the computed-BMI block below (matching the PDF's + // BMI line); skip the stored BODY_MASS_INDEX series here to avoid a + // duplicate Observation. + if (type === "BODY_MASS_INDEX") continue; + const reading = latestReading(data, type); + if (!reading) continue; + // SLEEP_DURATION is stored in MINUTES; the iOS-locked UCUM unit is `h`, so + // emit the value in hours to keep value and unit consistent. The PDF reads + // the raw series independently and is unaffected. + const value = + type === "SLEEP_DURATION" + ? Math.round((reading.value / 60) * 100) / 100 + : reading.value; + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept(mapping.category)], + code: codeableFromMapping(mapping), + subject: patientRef, + effectiveDateTime: reading.measuredAt, + valueQuantity: { + value, + unit: mapping.unit, + system: UCUM_SYSTEM, + code: mapping.unit, + }, + }); + } + + // --- Blood-pressure panel (sys + dia components) ----------------------- + const sys = latestReading(data, "BLOOD_PRESSURE_SYS"); + const dia = latestReading(data, "BLOOD_PRESSURE_DIA"); + if (sys && dia) { + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("vital-signs")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: BP_PANEL_LOINC, + display: "Blood pressure panel", + }, + ], + text: "Blood pressure", + }, + subject: patientRef, + // Use the systolic reading's timestamp as the panel effective time. + effectiveDateTime: sys.measuredAt, + component: [ + { + code: { + coding: [ + { system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" }, + ], + }, + valueQuantity: { + value: sys.value, + unit: BP_UNIT, + system: UCUM_SYSTEM, + code: BP_UNIT, + }, + }, + { + code: { + coding: [ + { system: LOINC_SYSTEM, code: BP_DIA_LOINC, display: "Diastolic" }, + ], + }, + valueQuantity: { + value: dia.value, + unit: BP_UNIT, + system: UCUM_SYSTEM, + code: BP_UNIT, + }, + }, + ], + }); + } + + // --- Computed BMI Observation (matches the PDF's BMI line) ------------- + if (data.bmi !== null && data.bmi !== undefined) { + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("vital-signs")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: "39156-5", + display: "Body mass index (BMI) [Ratio]", + }, + ], + text: "Body mass index (BMI) [Ratio]", + }, + subject: patientRef, + effectiveDateTime: data.period.end, + valueQuantity: { + value: data.bmi, + unit: "kg/m2", + system: UCUM_SYSTEM, + code: "kg/m2", + }, + }); + } + + // --- Glucose Observations (per context, user display unit) ------------- + for (const [ctx, stat] of Object.entries(data.glucoseStats)) { + const map = GLUCOSE_LOINC[ctx]; + if (!map) continue; + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("laboratory")], + code: { + coding: [{ system: LOINC_SYSTEM, code: map.loinc, display: map.display }], + text: map.display, + }, + subject: patientRef, + effectiveDateTime: data.period.end, + valueQuantity: { + value: convertGlucose(stat.latest, glucoseUnit), + unit: glucoseUnit, + system: UCUM_SYSTEM, + code: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL", + }, + }); + } + + // --- Medication-adherence Observations (one per medication) ------------ + for (const [name, comp] of Object.entries(data.compliance)) { + if (comp.total <= 0) continue; + obsSeq += 1; + const rate = Math.round((comp.taken / comp.total) * 1000) / 10; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("activity")], + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: MEDICATION_ADHERENCE_LOINC, + display: "Medication adherence", + }, + ], + text: `Medication adherence — ${name}`, + }, + subject: patientRef, + effectiveDateTime: data.period.end, + valueQuantity: { value: rate, unit: "%", system: UCUM_SYSTEM, code: "%" }, + }); + } + + // --- Mood Observation (opt-in only; absent when toggle off) ------------ + if (data.mood) { + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("vital-signs")], + code: { + coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }], + text: "Mood (average over period)", + }, + subject: patientRef, + effectiveDateTime: data.period.end, + valueQuantity: { + value: Math.round(data.mood.avg * 10) / 10, + unit: "{score}", + system: UCUM_SYSTEM, + code: "{score}", + }, + }); + } + + // --- Wellness-score Observations (descriptive composites) -------------- + // v1.10.0 — the server-derived nightly scores (recovery / stress / + // strain). They have no published LOINC term and are NOT clinical + // findings, so each is emitted under the `survey` category with a + // text-only concept and an explicit "descriptive, not a clinical + // assessment" note — a physician's FHIR viewer never mistakes a band for + // a diagnosis. Absent when the aggregator emitted no scores. + if (data.wellnessScores && data.wellnessScores.length > 0) { + for (const s of data.wellnessScores) { + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("survey")], + code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" }, + subject: patientRef, + effectiveDateTime: s.latestAt, + valueQuantity: { + value: s.latest, + unit: "{score}", + system: UCUM_SYSTEM, + code: "{score}", + }, + note: [ + { + text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.", + }, + ], + }); + } + } + + return observations; +} + +/** + * Emit one `MedicationStatement` per active medication. v1.9.0 — additive + * ATC (primary) / RxNorm (secondary) codings when the user stored them; + * falls back to the text-only concept otherwise. Ids run `med-1..N`. + */ +export function medicationStatementsFromReportData( + data: DoctorReportData, + options: FhirBuildOptions = {}, +): FhirMedicationStatement[] { + const germanAtc = options.germanAtc ?? false; + const statements: FhirMedicationStatement[] = []; + let medSeq = 0; + for (const med of data.medications) { + medSeq += 1; + const id = `med-${medSeq}`; + const stmt: FhirMedicationStatement = { + resourceType: "MedicationStatement", + id, + status: "active", + medicationCodeableConcept: medicationConcept( + med.name, + med.atcCode, + med.rxNormCode, + germanAtc, + ), + subject: patientRef, + }; + if (med.dose) stmt.dosage = [{ text: med.dose }]; + statements.push(stmt); + } + return statements; +} + +/** + * Emit one `MedicationAdministration` per acted intake: `completed` (taken) + * or `not-done` (explicitly skipped). Pending / missed slots and soft-deleted + * tombstones are excluded upstream by the aggregator. The concept reuses the + * same ATC/RxNorm coding as the statement so each administration is + * self-describing without resolving a reference (no `partOf` / `request` + * coupling). A `dosage` is emitted ONLY when a structured `dose` Quantity is + * available; a dosage with only `.text` would violate the R4 dose-or-rate + * invariant. Ids run `medadmin-1..N`. + */ +export function medicationAdministrationsFromReportData( + data: DoctorReportData, + options: FhirBuildOptions = {}, +): FhirMedicationAdministration[] { + const germanAtc = options.germanAtc ?? false; + const administrations: FhirMedicationAdministration[] = []; + let adminSeq = 0; + for (const admin of data.medicationAdministrations ?? []) { + adminSeq += 1; + const id = `medadmin-${adminSeq}`; + const resource: FhirMedicationAdministration = { + resourceType: "MedicationAdministration", + id, + status: admin.status, + medicationCodeableConcept: medicationConcept( + admin.medicationName, + admin.atcCode, + admin.rxNormCode, + germanAtc, + ), + subject: patientRef, + effectiveDateTime: admin.effectiveAt, + }; + + // Dosage: only when a structured dose Quantity exists. Carry the + // free-text dose + route + site alongside it. Route and site each carry + // an additive SNOMED coding plus the existing `.text` anchor. + if (admin.dose) { + const dosage: FhirDosage = { + dose: doseQuantity(admin.dose.value, admin.dose.unit), + }; + if (admin.doseText) dosage.text = admin.doseText; + const route = routeConcept(admin.deliveryForm); + if (route) dosage.route = route; + if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite); + resource.dosage = dosage; + } + + administrations.push(resource); + } + return administrations; +}