From 7ec2555d9a635b319914925347574d0f11046d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:03:15 +0200 Subject: [PATCH] feat(records): map allergies + family history to FHIR R4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit `AllergyIntolerance` and `FamilyMemberHistory` resources in the health-record document bundle. Vendor-blind and non-diagnostic, matching the illness `Condition` stance: the user's own label rides `code.text` (never a machine-guessed clinical code); only the FHIR-fixed value sets — allergy category, clinical/verification status, the v3 RoleCode family-member concept — carry codings. Every resource is marked patient-reported (allergy `verificationStatus: unconfirmed` plus an explicit "not a clinical diagnosis" note) so a clinician's viewer never reads it as an adjudicated finding. The records are loaded owner-scoped in the export route and folded into the fhir + package bundle formats with their own Composition sections. --- .../health-record/__tests__/route.test.ts | 4 + src/app/api/export/health-record/route.ts | 46 +++- src/lib/fhir/__tests__/records.test.ts | 100 ++++++++ src/lib/fhir/build-bundle.ts | 52 +++++ src/lib/fhir/records.ts | 214 ++++++++++++++++++ src/lib/fhir/types.ts | 46 ++++ 6 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 src/lib/fhir/__tests__/records.test.ts create mode 100644 src/lib/fhir/records.ts diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 67a9ba6eb..08ad8e15b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, labResult: { findMany: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + allergy: { findMany: vi.fn() }, + familyHistoryEntry: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, }, })); @@ -100,6 +102,8 @@ beforeEach(() => { vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); // First findUnique = aggregator profile select; second = route KVNR select. vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "sample", diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index f0cea3ba2..5c81f9372 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -34,6 +34,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; +import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto"; import { collectDoctorReportData, normaliseDateRange, @@ -112,14 +113,32 @@ export const POST = apiHandler(async (request: NextRequest) => { selection.germanAtc ?? (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale); - const [data, userTz, userRow] = await Promise.all([ - collectDoctorReportData(user.id, range, { practiceName, sections }), - resolveUserTimezone(user.id), - prisma.user.findUnique({ - where: { id: user.id }, - select: { insuranceNumberEncrypted: true, insightsCachedText: true }, - }), - ]); + const [data, userTz, userRow, allergyRows, familyHistoryRows] = + await Promise.all([ + collectDoctorReportData(user.id, range, { practiceName, sections }), + resolveUserTimezone(user.id), + prisma.user.findUnique({ + where: { id: user.id }, + select: { insuranceNumberEncrypted: true, insightsCachedText: true }, + }), + // v1.25 (W-RECORDS) — structured records are always-available reference + // data (not time-windowed), so they ride alongside the report rather + // than through `collectDoctorReportData`. Owner-scoped, live rows only. + prisma.allergy.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }), + prisma.familyHistoryEntry.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }), + ]); + + // FHIR records folded into every bundle format (fhir + package). + const fhirRecords = { + allergies: allergyRows.map(toAllergyDTO), + familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO), + }; // Decrypt the KVNR fail-soft: a key-rotation gap on one row should // never 500 the export — the cover/identifier just omits the value. @@ -163,6 +182,7 @@ export const POST = apiHandler(async (request: NextRequest) => { { insuranceNumber }, undefined, { germanAtc }, + fhirRecords, ); const json = JSON.stringify(bundle); annotate({ @@ -205,9 +225,13 @@ export const POST = apiHandler(async (request: NextRequest) => { } // format === "package": one zip holding the PDF + FHIR Bundle + a README. - const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, { - germanAtc, - }); + const bundle = buildFhirDocumentBundle( + data, + { insuranceNumber }, + undefined, + { germanAtc }, + fhirRecords, + ); const readme = t("doctorReport.packageReadme"); const zipped = zipSync( { diff --git a/src/lib/fhir/__tests__/records.test.ts b/src/lib/fhir/__tests__/records.test.ts new file mode 100644 index 000000000..7478b0809 --- /dev/null +++ b/src/lib/fhir/__tests__/records.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; + +import { + allergyIntoleranceResources, + familyMemberHistoryResources, +} from "@/lib/fhir/records"; +import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto"; + +function allergy(overrides: Partial = {}): AllergyDTO { + return { + id: "a1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: "2020-01-02T00:00:00.000Z", + reaction: "Anaphylaxis", + note: null, + createdAt: "2020-01-02T00:00:00.000Z", + updatedAt: "2020-01-02T00:00:00.000Z", + ...overrides, + }; +} + +function family( + overrides: Partial = {}, +): FamilyHistoryEntryDTO { + return { + id: "f1", + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 55, + note: null, + createdAt: "2020-01-02T00:00:00.000Z", + updatedAt: "2020-01-02T00:00:00.000Z", + ...overrides, + }; +} + +describe("allergyIntoleranceResources", () => { + it("maps a record to a patient-reported AllergyIntolerance with the label on code.text", () => { + const [r] = allergyIntoleranceResources([allergy()]); + expect(r.resourceType).toBe("AllergyIntolerance"); + expect(r.id).toBe("allergy-1"); + expect(r.code).toEqual({ text: "Penicillin" }); + expect(r.type).toBe("allergy"); + expect(r.category).toEqual(["medication"]); + expect(r.criticality).toBe("high"); + expect(r.clinicalStatus?.coding?.[0].code).toBe("active"); + // Patient-reported, never clinician-confirmed. + expect(r.verificationStatus?.coding?.[0].code).toBe("unconfirmed"); + expect(r.patient.reference).toBe("Patient/patient-1"); + expect(r.onsetDateTime).toBe("2020-01-02T00:00:00.000Z"); + expect(r.reaction?.[0].manifestation[0]).toEqual({ text: "Anaphylaxis" }); + expect(r.reaction?.[0].severity).toBe("severe"); + // First note is the non-diagnostic guard rail. + expect(r.note?.[0].text).toMatch(/not a clinical diagnosis/i); + }); + + it("omits category for OTHER and reaction when no free-text reaction", () => { + const [r] = allergyIntoleranceResources([ + allergy({ category: "OTHER", reaction: null, severity: null }), + ]); + expect(r.category).toBeUndefined(); + expect(r.reaction).toBeUndefined(); + expect(r.criticality).toBeUndefined(); + }); + + it("never machine-guesses a substance code", () => { + const [r] = allergyIntoleranceResources([allergy()]); + expect(r.code.coding).toBeUndefined(); + }); +}); + +describe("familyMemberHistoryResources", () => { + it("maps a record to a completed FamilyMemberHistory with the label on condition.code.text", () => { + const [r] = familyMemberHistoryResources([family()]); + expect(r.resourceType).toBe("FamilyMemberHistory"); + expect(r.id).toBe("famhist-1"); + expect(r.status).toBe("completed"); + expect(r.relationship.coding?.[0].code).toBe("MTH"); + expect(r.condition?.[0].code).toEqual({ text: "Type 2 diabetes" }); + expect(r.condition?.[0].onsetAge).toEqual({ + value: 55, + unit: "a", + system: "http://unitsofmeasure.org", + code: "a", + }); + expect(r.note?.[0].text).toMatch(/not a clinical diagnosis/i); + }); + + it("falls back to the generic family-member RoleCode for OTHER and omits onsetAge when unknown", () => { + const [r] = familyMemberHistoryResources([ + family({ relationship: "OTHER", ageAtOnset: null }), + ]); + expect(r.relationship.coding?.[0].code).toBe("FAMMEMB"); + expect(r.condition?.[0].onsetAge).toBeUndefined(); + }); +}); diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 4003a75b0..a30a9e99b 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -24,6 +24,11 @@ * user-supplied HTML (no markdown library, no `dangerouslySetInnerHTML`). */ import type { DoctorReportData } from "@/lib/doctor-report-data"; +import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto"; +import { + allergyIntoleranceResources, + familyMemberHistoryResources, +} from "@/lib/fhir/records"; import { LOINC_SYSTEM } from "@/lib/fhir/loinc-map"; import { type FhirBuildOptions, @@ -66,6 +71,17 @@ function escapeXml(value: string): string { .replace(/'/g, "'"); } +/** + * v1.25 (W-RECORDS) — the structured records folded into the document bundle. + * Passed separately from `DoctorReportData` (they are always-available + * reference records, not part of the time-windowed report aggregation). + * Empty arrays emit nothing. + */ +export interface FhirRecordInputs { + allergies?: AllergyDTO[]; + familyHistory?: FamilyHistoryEntryDTO[]; +} + /** * Build a FHIR R4 document Bundle from the aggregated report data. * @@ -76,6 +92,7 @@ export function buildFhirDocumentBundle( identity: FhirPatientIdentity, now: Date = new Date(), options: FhirBuildOptions = {}, + records: FhirRecordInputs = {}, ): FhirBundle { const patientRef: FhirReference = { reference: `Patient/${PATIENT_RESOURCE_ID}`, @@ -86,6 +103,8 @@ export function buildFhirDocumentBundle( const medicationRefs: FhirReference[] = []; const administrationRefs: FhirReference[] = []; const conditionRefs: FhirReference[] = []; + const allergyRefs: FhirReference[] = []; + const familyHistoryRefs: FhirReference[] = []; // --- Patient ----------------------------------------------------------- const patient = patientResource(data, identity); @@ -140,6 +159,20 @@ export function buildFhirDocumentBundle( entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter }); } + // --- AllergyIntolerance per recorded allergy (v1.25) ------------------- + // Always-available structured records (not time-windowed). Patient-reported + // (verificationStatus unconfirmed); the substance rides `code.text`. + for (const allergy of allergyIntoleranceResources(records.allergies ?? [])) { + entries.push({ fullUrl: `urn:uuid:${allergy.id}`, resource: allergy }); + allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` }); + } + + // --- FamilyMemberHistory per recorded entry (v1.25) ------------------- + for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) { + entries.push({ fullUrl: `urn:uuid:${fmh.id}`, resource: fmh }); + familyHistoryRefs.push({ reference: `FamilyMemberHistory/${fmh.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 @@ -226,6 +259,25 @@ export function buildFhirDocumentBundle( }, ] : []), + // v1.25 — structured allergy records (AllergyIntolerance). Present only + // when the account recorded at least one allergy. + ...(allergyRefs.length > 0 + ? [ + { + title: "Allergies", + entry: allergyRefs, + }, + ] + : []), + // v1.25 — structured family-history records (FamilyMemberHistory). + ...(familyHistoryRefs.length > 0 + ? [ + { + title: "Family history", + entry: familyHistoryRefs, + }, + ] + : []), ], }; diff --git a/src/lib/fhir/records.ts b/src/lib/fhir/records.ts new file mode 100644 index 000000000..df86985f1 --- /dev/null +++ b/src/lib/fhir/records.ts @@ -0,0 +1,214 @@ +/** + * v1.25 (W-RECORDS) — FHIR R4 emitters for the structured health records. + * + * Pure functions over the server-authoritative DTOs (`@/lib/records/dto`): + * - Allergy → `AllergyIntolerance` + * - FamilyHistoryEntry → `FamilyMemberHistory` + * + * Vendor-blind + non-diagnostic, matching the illness `Condition` stance in + * `./resources`: the user's own label rides `code.text` (NEVER a + * machine-guessed SNOMED/RxNorm code); only the FHIR-defined value sets + * (category, clinical/verification status, family-member RoleCode) carry + * codings. Every resource is marked patient-reported (allergy + * `verificationStatus: unconfirmed`; an explicit "not a clinical diagnosis" + * note) so a clinician's viewer never reads it as an adjudicated finding. + * + * No FHIR SDK — narrow hand-rolled interfaces only (`./types`), matching the + * project's "hand-rolled over the documented wire" convention. All text is + * plain text; never user-supplied HTML. + */ +import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto"; +import { PATIENT_RESOURCE_ID } from "@/lib/fhir/resources"; +import type { + FhirAllergyIntolerance, + FhirFamilyMemberHistory, + FhirReference, +} from "@/lib/fhir/types"; + +const patientRef: FhirReference = { + reference: `Patient/${PATIENT_RESOURCE_ID}`, +}; + +const ALLERGY_CLINICAL_SYSTEM = + "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical"; +const ALLERGY_VERIFICATION_SYSTEM = + "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification"; + +/** FHIR-fixed `AllergyIntolerance.clinicalStatus` per record status. */ +const ALLERGY_CLINICAL: Record = { + ACTIVE: { code: "active", display: "Active" }, + INACTIVE: { code: "inactive", display: "Inactive" }, + RESOLVED: { code: "resolved", display: "Resolved" }, +}; + +/** Record category → the fixed `AllergyIntolerance.category` value set. */ +const ALLERGY_CATEGORY: Record< + string, + "food" | "medication" | "environment" | "biologic" | undefined +> = { + FOOD: "food", + MEDICATION: "medication", + ENVIRONMENT: "environment", + BIOLOGIC: "biologic", + OTHER: undefined, +}; + +/** Record severity → the fixed `reaction.severity` value set. */ +const ALLERGY_SEVERITY: Record = { + MILD: "mild", + MODERATE: "moderate", + SEVERE: "severe", +}; + +const ALLERGY_TYPE: Record = { + ALLERGY: "allergy", + INTOLERANCE: "intolerance", +}; + +/** + * Emit one `AllergyIntolerance` per record. Ids run `allergy-1..N`. The + * substance rides `code.text`; the broad category + clinical/verification + * status carry their FHIR-fixed codings. `verificationStatus` is always + * `unconfirmed` (patient-reported). The decrypted reaction free-text rides + * `reaction[].manifestation.text` with the graded severity; an explicit note + * marks the record as self-recorded. + */ +export function allergyIntoleranceResources( + allergies: AllergyDTO[], +): FhirAllergyIntolerance[] { + const out: FhirAllergyIntolerance[] = []; + let seq = 0; + for (const a of allergies) { + seq += 1; + const clinical = ALLERGY_CLINICAL[a.status] ?? ALLERGY_CLINICAL.ACTIVE; + const category = ALLERGY_CATEGORY[a.category]; + const severity = a.severity ? ALLERGY_SEVERITY[a.severity] : undefined; + const resource: FhirAllergyIntolerance = { + resourceType: "AllergyIntolerance", + id: `allergy-${seq}`, + clinicalStatus: { + coding: [ + { + system: ALLERGY_CLINICAL_SYSTEM, + code: clinical.code, + display: clinical.display, + }, + ], + }, + verificationStatus: { + coding: [ + { + system: ALLERGY_VERIFICATION_SYSTEM, + // Patient-reported record → "unconfirmed", never clinician-confirmed. + code: "unconfirmed", + display: "Unconfirmed", + }, + ], + }, + type: ALLERGY_TYPE[a.type] ?? "allergy", + ...(category ? { category: [category] } : {}), + ...(severity + ? { criticality: severity === "severe" ? "high" : "low" } + : {}), + // The user's own substance label — never a machine-guessed code. + code: { text: a.substance }, + patient: patientRef, + ...(a.onsetAt ? { onsetDateTime: a.onsetAt } : {}), + note: [ + { + text: "Self-recorded allergy/intolerance; patient-reported, not a clinical diagnosis.", + }, + ...(a.note ? [{ text: a.note }] : []), + ], + }; + if (a.reaction) { + resource.reaction = [ + { + manifestation: [{ text: a.reaction }], + ...(severity ? { severity } : {}), + }, + ]; + } + out.push(resource); + } + return out; +} + +const ROLE_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-RoleCode"; + +/** + * Record relationship → HL7 v3 `RoleCode` family-member concept (the fixed + * code system FHIR `FamilyMemberHistory.relationship` examples use). `OTHER` + * falls back to the generic `FAMMEMB` (family member) concept — an honest + * fallback, never a fabricated specific relation. `display` carries a stable + * English label; the localized label lives on the client surface. + */ +const RELATIONSHIP_ROLE: Record = { + MOTHER: { code: "MTH", display: "Mother" }, + FATHER: { code: "FTH", display: "Father" }, + SISTER: { code: "SIS", display: "Sister" }, + BROTHER: { code: "BRO", display: "Brother" }, + DAUGHTER: { code: "DAUC", display: "Daughter" }, + SON: { code: "SONC", display: "Son" }, + GRANDMOTHER_MATERNAL: { code: "MGRMTH", display: "Maternal grandmother" }, + GRANDFATHER_MATERNAL: { code: "MGRFTH", display: "Maternal grandfather" }, + GRANDMOTHER_PATERNAL: { code: "PGRMTH", display: "Paternal grandmother" }, + GRANDFATHER_PATERNAL: { code: "PGRFTH", display: "Paternal grandfather" }, + AUNT: { code: "AUNT", display: "Aunt" }, + UNCLE: { code: "UNCLE", display: "Uncle" }, + COUSIN: { code: "COUSN", display: "Cousin" }, + HALF_SIBLING: { code: "HSIB", display: "Half-sibling" }, + OTHER: { code: "FAMMEMB", display: "Family member" }, +}; + +/** + * Emit one `FamilyMemberHistory` per entry (one condition each). Ids run + * `famhist-1..N`. The relationship carries the v3 RoleCode coding + a readable + * `.text`; the condition rides `condition[].code.text` (no machine-guessed + * code) with an optional `onsetAge` in years (UCUM `a`). `status: "completed"`. + */ +export function familyMemberHistoryResources( + entries: FamilyHistoryEntryDTO[], +): FhirFamilyMemberHistory[] { + const out: FhirFamilyMemberHistory[] = []; + let seq = 0; + for (const e of entries) { + seq += 1; + const role = RELATIONSHIP_ROLE[e.relationship] ?? RELATIONSHIP_ROLE.OTHER; + out.push({ + resourceType: "FamilyMemberHistory", + id: `famhist-${seq}`, + status: "completed", + patient: patientRef, + relationship: { + coding: [ + { system: ROLE_CODE_SYSTEM, code: role.code, display: role.display }, + ], + text: role.display, + }, + condition: [ + { + // The user's own condition label — never a machine-guessed code. + code: { text: e.condition }, + ...(e.ageAtOnset !== null + ? { + onsetAge: { + value: e.ageAtOnset, + unit: "a", + system: "http://unitsofmeasure.org", + code: "a", + }, + } + : {}), + }, + ], + note: [ + { + text: "Self-recorded family history; patient-reported, not a clinical diagnosis.", + }, + ...(e.note ? [{ text: e.note }] : []), + ], + }); + } + return out; +} diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 303d327d7..95e29c9eb 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -245,6 +245,50 @@ export interface FhirEncounter { reasonReference?: FhirReference[]; } +/** + * v1.25 (W-RECORDS) — R4 `AllergyIntolerance`. A self-recorded allergy / + * intolerance. `clinicalStatus`/`verificationStatus` carry "what the user + * asserts" (verification is always `unconfirmed` — patient-reported, not a + * clinician-confirmed diagnosis). The substance rides `code.text` (no + * machine-guessed SNOMED/RxNorm code); the broad category rides `category[]`. + */ +export interface FhirAllergyIntolerance { + resourceType: "AllergyIntolerance"; + id: string; + clinicalStatus?: FhirCodeableConcept; + verificationStatus?: FhirCodeableConcept; + type?: "allergy" | "intolerance"; + category?: ("food" | "medication" | "environment" | "biologic")[]; + criticality?: "low" | "high" | "unable-to-assess"; + code: FhirCodeableConcept; + patient: FhirReference; + onsetDateTime?: string; + reaction?: { + manifestation: FhirCodeableConcept[]; + severity?: "mild" | "moderate" | "severe"; + }[]; + note?: FhirAnnotation[]; +} + +/** + * v1.25 (W-RECORDS) — R4 `FamilyMemberHistory`. One relative's condition as + * recorded by the user. `relationship` carries the SNOMED family-member + * concept; the condition rides `condition[].code.text` (no machine-guessed + * code). `status: "completed"` marks the record as captured. Patient-reported. + */ +export interface FhirFamilyMemberHistory { + resourceType: "FamilyMemberHistory"; + id: string; + status: "partial" | "completed" | "entered-in-error" | "health-unknown"; + patient: FhirReference; + relationship: FhirCodeableConcept; + condition?: { + code: FhirCodeableConcept; + onsetAge?: FhirQuantity; + }[]; + note?: FhirAnnotation[]; +} + export type FhirResource = | FhirComposition | FhirPatient @@ -254,6 +298,8 @@ export type FhirResource = | FhirMedicationAdministration | FhirCondition | FhirEncounter + | FhirAllergyIntolerance + | FhirFamilyMemberHistory | FhirDiagnosticReport; export interface FhirBundleEntry {