From 7fa2866154d03fb0aef8b469caa067100c892337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:42:39 +0200 Subject: [PATCH] feat(fhir): emit Coverage on a bare KVNR + add Composition.text Align the server's `Coverage` emission with the iOS exporter: a Coverage is now emitted whenever ANY payor signal is present, including a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the `subscriberId` with no contained payor Organization; the insurer-known case is unchanged. A bundle with no payor signal at all still omits Coverage entirely. Also add a top-level `Composition.text` narrative (reusing the same escaped summary the section narrative carries) so a strict US-Core-style validator sees the document-level narrative it expects. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43) --- src/lib/fhir/__tests__/build-bundle.test.ts | 46 ++++++++++++++++++-- src/lib/fhir/build-bundle.ts | 48 +++++++++++++-------- src/lib/fhir/types.ts | 6 +++ 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 62c840260..c3aa8a1b7 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -241,7 +241,7 @@ describe("buildFhirDocumentBundle", () => { expect(coverage?.contained?.[0].identifier?.[0].value).toBe("101234567"); }); - it("omits the Coverage entirely when neither insurer name nor IKNR is present", () => { + it("omits the Coverage entirely when there is no payor signal at all", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { @@ -253,17 +253,57 @@ describe("buildFhirDocumentBundle", () => { // neither insurerName nor insurerIkNumber }, }), - { insuranceNumber: "A123456780" }, + // ...and no KVNR. + { insuranceNumber: null }, FIXED_NOW, ); expect(coverageOf(bundle)).toBeUndefined(); - // KVNR still rides on Patient.identifier. + }); + + it("emits a KVNR-only Coverage (no insurer) to align with the iOS exporter", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + // neither insurerName nor insurerIkNumber — just a bare KVNR. + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.status).toBe("active"); + expect(coverage?.beneficiary.reference).toBe("Patient/patient-1"); + expect(coverage?.subscriberId).toBe("A123456780"); + // No insurer known → no contained payor Organization, no payor[]. + expect(coverage?.contained).toBeUndefined(); + expect(coverage?.payor).toBeUndefined(); + // KVNR still rides on Patient.identifier too. const patient = bundle.entry.find( (e) => e.resource.resourceType === "Patient", )?.resource as FhirPatient; expect(patient.identifier?.[0].value).toBe("A123456780"); }); + it("emits a top-level Composition.text narrative", () => { + const bundle = buildFhirDocumentBundle( + makeData(), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource; + expect(composition.resourceType).toBe("Composition"); + if (composition.resourceType === "Composition") { + expect(composition.text?.status).toBe("generated"); + expect(composition.text?.div).toContain("Health record for"); + } + }); + it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 7227ba4d5..2578c6912 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -226,33 +226,36 @@ export function buildFhirDocumentBundle( entries.push({ fullUrl: `urn:uuid:${patientId}`, resource: patient }); // --- Coverage (insurer; sits right after the Patient) ------------------ - // Emitted whenever ANY payor info is present (insurer name and/or IKNR). - // The payor is a CONTAINED Organization referenced by a local `#`-ref. - // The KVNR stays on `Patient.identifier`; here it doubles as the - // Coverage `subscriberId` (the member id) when present. When neither an - // insurer name nor an IKNR is known, omit Coverage entirely rather than - // emit an empty payor. + // 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; - if (insurerName || insurerIkNumber) { - 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; - + const hasPayorOrg = Boolean(insurerName || insurerIkNumber); + if (hasPayorOrg || identity.insuranceNumber) { const coverage: FhirCoverage = { resourceType: "Coverage", id: "coverage-1", status: "active", - contained: [payorOrg], beneficiary: patientRef, - payor: [{ reference: `#${orgId}` }], }; + 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; } @@ -555,6 +558,13 @@ export function buildFhirDocumentBundle( 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: `
${escapeXml(narrativeText)}
`, + }, section: [ { // Vital-signs section carries the narrative + every Observation ref, diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 55a375235..649e6f54f 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -175,6 +175,12 @@ export interface FhirComposition { date: string; author: FhirReference[]; title: string; + /** + * 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?: FhirNarrative; section?: FhirCompositionSection[]; }