mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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",
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user