From 8934a2900e05ba76499ce747749c38b765eb7820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 12:45:15 +0200 Subject: [PATCH] feat(fhir): export the body site and side of a condition Condition.bodySite carries the free-text site as text. The side rides a contained BodyStructure reached through the core bodySite extension, coded with the SNOMED CT concepts of the R4 bodysite-laterality value set, and is repeated in the text so a receiver that ignores extensions still reads it. --- .../__tests__/condition-body-site.test.ts | 126 ++++++++++++++++++ src/lib/fhir/__tests__/conformance.test.ts | 30 +++++ src/lib/fhir/resources/conditions.ts | 81 +++++++++++ src/lib/fhir/types.ts | 32 +++++ 4 files changed, 269 insertions(+) create mode 100644 src/lib/fhir/__tests__/condition-body-site.test.ts diff --git a/src/lib/fhir/__tests__/condition-body-site.test.ts b/src/lib/fhir/__tests__/condition-body-site.test.ts new file mode 100644 index 000000000..8d44b95c1 --- /dev/null +++ b/src/lib/fhir/__tests__/condition-body-site.test.ts @@ -0,0 +1,126 @@ +/** + * `Condition.bodySite` from the condition journal's free-text site and side. + * + * The site rides `text`; the side rides a contained `BodyStructure` reached + * through the core `bodySite` extension, and is repeated in `text` so a + * receiver that ignores extensions still reads which side. + */ +import { describe, expect, it } from "vitest"; + +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { conditionsFromReportData } from "@/lib/fhir/resources"; +import type { FhirCondition } from "@/lib/fhir/types"; + +type Episode = NonNullable[number]; + +function conditionFor(extra: Partial): FhirCondition { + const data = { + illnessEpisodes: [ + { + label: "Sprain", + type: "INJURY", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: null, + ...extra, + }, + ], + } as unknown as DoctorReportData; + const [condition] = conditionsFromReportData(data).conditions; + if (!condition) throw new Error("no Condition emitted"); + return condition; +} + +describe("conditionsFromReportData — bodySite", () => { + it("emits no bodySite and nothing contained when no site was recorded", () => { + const c = conditionFor({}); + expect(c.bodySite).toBeUndefined(); + expect(c.contained).toBeUndefined(); + }); + + it("treats a blank site as no site, even with a side", () => { + const c = conditionFor({ bodySite: " ", laterality: "LEFT" }); + expect(c.bodySite).toBeUndefined(); + expect(c.contained).toBeUndefined(); + }); + + it("emits the site as text only when no side was stated", () => { + const c = conditionFor({ bodySite: "Lower back", laterality: null }); + expect(c.bodySite).toEqual([{ text: "Lower back" }]); + expect(c.contained).toBeUndefined(); + }); + + it.each([ + ["LEFT", "419161000", "Unilateral left", "left"], + ["RIGHT", "419465000", "Unilateral right", "right"], + ["BOTH", "51440002", "Bilateral", "both sides"], + ])( + "carries %s as SNOMED %s on a contained BodyStructure", + (laterality, code, display, sideText) => { + const c = conditionFor({ bodySite: "Knee", laterality }); + expect(c.bodySite).toEqual([ + { + extension: [ + { + url: "http://hl7.org/fhir/StructureDefinition/bodySite", + valueReference: { reference: "#bodysite-1" }, + }, + ], + text: `Knee (${sideText})`, + }, + ]); + expect(c.contained).toEqual([ + { + resourceType: "BodyStructure", + id: "bodysite-1", + location: { text: "Knee" }, + locationQualifier: [ + { + coding: [{ system: "http://snomed.info/sct", code, display }], + text: sideText, + }, + ], + patient: { reference: "Patient/patient-1" }, + }, + ]); + }, + ); + + it("never puts the side into bodySite.coding", () => { + const c = conditionFor({ bodySite: "Knee", laterality: "RIGHT" }); + expect(c.bodySite?.[0]?.coding).toBeUndefined(); + }); + + it("falls back to the site alone for an unknown side value", () => { + const c = conditionFor({ bodySite: "Knee", laterality: "SIDEWAYS" }); + expect(c.bodySite).toEqual([{ text: "Knee" }]); + expect(c.contained).toBeUndefined(); + }); + + it("keeps each episode's site on its own Condition", () => { + const data = { + illnessEpisodes: [ + { + label: "A", + type: "INJURY", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: null, + bodySite: "Wrist", + laterality: "RIGHT", + }, + { + label: "B", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-04-02T00:00:00.000Z", + resolvedAt: null, + }, + ], + } as unknown as DoctorReportData; + const [a, b] = conditionsFromReportData(data).conditions; + expect(a?.bodySite?.[0]?.text).toBe("Wrist (right)"); + expect(b?.bodySite).toBeUndefined(); + expect(b?.contained).toBeUndefined(); + }); +}); diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 8877f7dad..7e423e1ef 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -100,6 +100,8 @@ function makeData(overrides?: Partial): DoctorReportData { lifecycle: "ACUTE", onsetAt: "2026-04-01T00:00:00.000Z", resolvedAt: "2026-04-10T00:00:00.000Z", + bodySite: "Knee", + laterality: "LEFT", }, ], mood: { avg: 3.8, count: 20 }, @@ -173,6 +175,34 @@ function resourcesOf(bundle: FhirBundle): FhirResource[] { return bundle.entry.map((e) => e.resource); } +// ── Condition.bodySite — the contained BodyStructure resolves in place ─── + +describe("Condition.bodySite", () => { + it("points its extension at a BodyStructure contained in the same Condition, whose patient resolves", () => { + const bundle = fullBundle(); + const patientUrl = bundle.entry.find( + (e) => e.resource.resourceType === "Patient", + )?.fullUrl; + const condition = resourcesOf(bundle).find( + (r) => r.resourceType === "Condition", + ); + if (condition?.resourceType !== "Condition") + throw new Error("no Condition"); + const ext = condition.bodySite?.[0]?.extension?.[0]; + expect(ext?.url).toBe("http://hl7.org/fhir/StructureDefinition/bodySite"); + const target = ext?.valueReference.reference; + const structure = condition.contained?.find((c) => `#${c.id}` === target); + expect(structure?.resourceType).toBe("BodyStructure"); + expect(structure?.patient.reference).toBe(patientUrl); + // A code from the R4 core bodysite-laterality value set. + expect( + ["419161000", "419465000", "51440002"].includes( + structure?.locationQualifier?.[0]?.coding?.[0]?.code ?? "", + ), + ).toBe(true); + }); +}); + // ── Test 14 — document identity, entry identity, reference resolution ───── describe("document Bundle — identity and resolvability", () => { diff --git a/src/lib/fhir/resources/conditions.ts b/src/lib/fhir/resources/conditions.ts index 1a4984855..6728b2179 100644 --- a/src/lib/fhir/resources/conditions.ts +++ b/src/lib/fhir/resources/conditions.ts @@ -10,6 +10,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed"; import type { + FhirBodyStructure, FhirCodeableConcept, FhirCondition, FhirEncounter, @@ -19,6 +20,83 @@ import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common"; /** SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. */ const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const; +/** + * The side of a body site, as SNOMED CT laterality qualifiers from the R4 core + * value set `http://hl7.org/fhir/ValueSet/bodysite-laterality` + * (https://hl7.org/fhir/R4/valueset-bodysite-laterality.html); the same three + * concepts sit in `bodystructure-relative-location`, the value set R4 binds to + * `BodyStructure.locationQualifier`. Keyed on the `Laterality` enum. + */ +const LATERALITY_SNOMED: Record< + string, + { code: string; display: string; text: string } +> = { + LEFT: { code: "419161000", display: "Unilateral left", text: "left" }, + RIGHT: { code: "419465000", display: "Unilateral right", text: "right" }, + BOTH: { code: "51440002", display: "Bilateral", text: "both sides" }, +}; + +/** + * Core extension `http://hl7.org/fhir/StructureDefinition/bodySite` + * (https://hl7.org/fhir/extensions/StructureDefinition-bodySite.html): a + * Reference(BodyStructure) on `Condition.bodySite`, unchanged in R4. + */ +const BODY_SITE_EXTENSION_URL = + "http://hl7.org/fhir/StructureDefinition/bodySite"; + +const BODY_STRUCTURE_ID = "bodysite-1"; + +/** + * `Condition.bodySite` plus the contained `BodyStructure` that carries its + * side, or nothing when no site was recorded. + * + * The site is the user's free text, so it rides `text` only; no SNOMED body + * structure is guessed from it. R4 `Condition.bodySite` has no laterality + * element, and a laterality concept is not a body site, so putting "Left" in + * `bodySite.coding` would claim the condition sits on "left". The standard R4 + * route is the core `bodySite` extension pointing at a `BodyStructure` whose + * `locationQualifier` holds the SNOMED CT side. The side is also written into + * `bodySite.text`, because a receiver that ignores extensions must still read + * "Knee (left)" rather than a bare "Knee". + */ +function bodySiteOf( + bodySite: string | null | undefined, + laterality: string | null | undefined, +): Pick { + const site = bodySite?.trim(); + if (!site) return {}; + const side = laterality ? LATERALITY_SNOMED[laterality] : undefined; + if (!side) return { bodySite: [{ text: site }] }; + const structure: FhirBodyStructure = { + resourceType: "BodyStructure", + id: BODY_STRUCTURE_ID, + location: { text: site }, + locationQualifier: [ + { + coding: [ + { system: SNOMED_SYSTEM, code: side.code, display: side.display }, + ], + text: side.text, + }, + ], + patient: patientRef, + }; + return { + contained: [structure], + bodySite: [ + { + extension: [ + { + url: BODY_SITE_EXTENSION_URL, + valueReference: { reference: `#${BODY_STRUCTURE_ID}` }, + }, + ], + text: `${site} (${side.text})`, + }, + ], + }; +} + /** R4 `Condition.clinicalStatus` concept for the given resolution state. */ function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept { return { @@ -53,9 +131,11 @@ export function conditionsFromReportData(data: DoctorReportData): { seq += 1; const conditionId = `condition-${seq}`; const resolved = ep.resolvedAt !== null; + const site = bodySiteOf(ep.bodySite, ep.laterality); conditions.push({ resourceType: "Condition", id: conditionId, + ...(site.contained ? { contained: site.contained } : {}), clinicalStatus: conditionClinicalStatus(resolved), verificationStatus: { coding: [ @@ -94,6 +174,7 @@ export function conditionsFromReportData(data: DoctorReportData): { ], text: ep.label, }, + ...(site.bodySite ? { bodySite: site.bodySite } : {}), subject: patientRef, onsetDateTime: ep.onsetAt, ...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}), diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 00e85cc95..649d08a32 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -15,10 +15,21 @@ export interface FhirCoding { } export interface FhirCodeableConcept { + /** + * Element extensions. Only `Condition.bodySite` carries one today (the core + * `bodySite` extension pointing at a contained `BodyStructure`). + */ + extension?: FhirExtension[]; coding?: FhirCoding[]; text?: string; } +/** R4 `Extension`, narrowed to the one value type HealthLog emits. */ +export interface FhirExtension { + url: string; + valueReference: FhirReference; +} + export interface FhirReference { reference: string; display?: string; @@ -250,10 +261,17 @@ export interface FhirComposition { export interface FhirCondition { resourceType: "Condition"; id: string; + /** + * v1.39.3 — the `BodyStructure` that carries the side of the body site, + * referenced from `bodySite[].extension` by a local `#`-ref. + */ + contained?: FhirBodyStructure[]; clinicalStatus?: FhirCodeableConcept; verificationStatus?: FhirCodeableConcept; category?: FhirCodeableConcept[]; code: FhirCodeableConcept; + /** v1.39.3 — where on the body, as the user wrote it (`text` only). */ + bodySite?: FhirCodeableConcept[]; subject: FhirReference; onsetDateTime?: string; abatementDateTime?: string; @@ -261,6 +279,20 @@ export interface FhirCondition { note?: FhirAnnotation[]; } +/** + * v1.39.3 — R4 `BodyStructure`, emitted only as a resource contained in a + * `Condition`. `location` repeats the free-text site; `locationQualifier` + * carries the side as a SNOMED CT laterality concept. `patient` is `1..1` in + * R4. + */ +export interface FhirBodyStructure { + resourceType: "BodyStructure"; + id: string; + location?: FhirCodeableConcept; + locationQualifier?: FhirCodeableConcept[]; + patient: FhirReference; +} + /** * v1.18.1 P4 — R4 `Encounter`. Emitted alongside a resolved episode to bound * the period of care (onset → resolution) so a clinician sees the episode as a