fix(fhir): leave out the vital-signs DiagnosticReport when there are no vital signs

A window without a vital sign emitted the panel with result: [], which R4
rejects. The DiagnosticReport now goes out only when it has a result, and
the vital-signs section drops an empty entry list rather than sending one.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 13:31:38 +02:00
1 parent 87526e3df6
commit 35327b84c1
5 files changed
+99 -33

No files matched your search

+4 -3
View File
@@ -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
@@ -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
@@ -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<DoctorReportData>),
{ 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(),
+40 -28
View File
@@ -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: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
},
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
+1 -1
View File
@@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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(),