diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89ecc1799..2dc9a4a6f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1478,9 +1478,10 @@ paths: summary: FHIR R4 $everything (v1.11.0) description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, - Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one - `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. - Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`." + Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the + window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report + selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` + (≤200) / `_offset`." parameters: - in: query name: _count diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index df9318b9f..bf596b780 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -5,7 +5,8 @@ * the Composition, the authoring Device, Patient, Coverage, every Observation * (vitals / glucose / labs / adherence / mood / wellness AND the cycle set), * MedicationStatements, MedicationAdministrations, Conditions, Encounters, - * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport. + * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present + * when the window holds a vital sign). * * The set is the document export's, flattened — same builder, same order, same * `urn:uuid` entry identities, so a reference between two resources still diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index a0c2f5f07..a7d350c4e 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -9,6 +9,7 @@ import type { FhirPatient, FhirCoverage, FhirDiagnosticReport, + FhirComposition, } from "../types"; function administrationsOf( @@ -1172,6 +1173,57 @@ describe("buildFhirDocumentBundle", () => { ); }); + it("emits no DiagnosticReport, and no empty array anywhere, when the window holds no vital sign", () => { + const bundle = buildFhirDocumentBundle( + makeData({ measurements: {}, bmi: null }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const types = bundle.entry.map((e) => e.resource.resourceType); + expect(types).not.toContain("DiagnosticReport"); + const composition = bundle.entry[0].resource as FhirComposition; + const refs = (composition.section ?? []).flatMap((s) => s.entry ?? []); + for (const ref of refs) { + expect(refTarget(bundle, ref.reference)).not.toMatch( + /^DiagnosticReport\//, + ); + } + // R4: an array element is absent, never empty. + const emptyArrays: string[] = []; + const walk = (value: unknown, path: string): void => { + if (Array.isArray(value)) { + if (value.length === 0) emptyArrays.push(path); + value.forEach((item, i) => walk(item, `${path}[${i}]`)); + } else if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`); + } + }; + walk(bundle, "Bundle"); + expect(emptyArrays).toEqual([]); + }); + + it("leaves the vital-signs section without `entry` when it has nothing to list", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + measurements: {}, + bmi: null, + glucoseStats: {}, + compliance: {}, + medications: [], + mood: null, + labResults: [], + } as Partial), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource as FhirComposition; + const vitals = composition.section?.find((s) => s.title === "Vital signs"); + expect(vitals?.text?.div).toBeTruthy(); + expect( + vitals && "entry" in vitals ? vitals.entry : undefined, + ).toBeUndefined(); + }); + it("appends a DiagnosticReport as the LAST entry routing all Observation refs", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 8c1fc2edb..fe0309f10 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -328,31 +328,39 @@ export function buildFhirDocumentBundle( : []), ].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}`, - }; + // A vital-signs panel with no vital signs reports nothing, and R4 forbids + // the empty `result: []` it would otherwise carry. With no vital-sign + // Observation in the window the DiagnosticReport is left out entirely, + // together with its Composition entry. + const diagnosticReport: FhirDiagnosticReport | null = + vitalSignsObservationRefs.length === 0 + ? null + : { + 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 diagnosticReportRefs: FhirReference[] = diagnosticReport + ? [{ reference: `DiagnosticReport/${diagnosticReport.id}` }] + : []; + const vitalSignsEntries = [...observationRefs, ...diagnosticReportRefs]; const composition: FhirComposition = { resourceType: "Composition", @@ -390,7 +398,9 @@ export function buildFhirDocumentBundle( status: "generated", div: `
${escapeXml(narrativeText)}
`, }, - entry: [...observationRefs, diagnosticReportRef], + // `entry` is omitted rather than empty; the section still carries + // its narrative, which satisfies `cmp-2`. + ...(vitalSignsEntries.length > 0 ? { entry: vitalSignsEntries } : {}), }, // Medications section carries both the active-medication // statements and the per-dose administration records (v1.9.0). @@ -479,11 +489,13 @@ export function buildFhirDocumentBundle( }; // The Composition must be the FIRST entry in a document Bundle; the - // DiagnosticReport is the LAST. + // DiagnosticReport, when there is one, is the LAST. const orderedEntries: FhirBundleEntry[] = [ { fullUrl: `urn:uuid:${randomUUID()}`, resource: composition }, ...entries, - { fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport }, + ...(diagnosticReport + ? [{ fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport }] + : []), ]; // Every entry identity is known only now, so the reference rewrite runs once diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 0da6adf1f..4e8fdadb7 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 $everything (v1.11.0)", description: - "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", requestParams: { query: z.object({ _count: z.coerce.number().optional(),