feat(records): map allergies + family history to FHIR R4

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:03:15 +02:00
1 parent 5052b2cc08
commit 7ec2555d9a
6 files changed
+451 -11

No files matched your search

@@ -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",
+35 -11
View File
@@ -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(
{
+100
View File
@@ -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> = {}): 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> = {},
): 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();
});
});
+52
View File
@@ -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, "&#39;");
}
/**
* 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,
},
]
: []),
],
};
+214
View File
@@ -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<string, { code: string; display: string }> = {
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<string, "mild" | "moderate" | "severe"> = {
MILD: "mild",
MODERATE: "moderate",
SEVERE: "severe",
};
const ALLERGY_TYPE: Record<string, "allergy" | "intolerance"> = {
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<string, { code: string; display: string }> = {
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;
}
+46
View File
@@ -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 {