Skip to content

Commit ab49683

Browse files
feat(epic): materialise Observation/Condition/MedicationRequest/AllergyIntolerance into native tables (#120)
- Add migration 007_clinical_detail.sql: patient_conditions, patient_medications, patient_allergies tables with RLS, indexes, and updated_at triggers - Add conditionService, medicationService, allergyService with create/listForPatient following the same org-scoped + audit pattern as labResultService - Rewrite importPatient.js: Epic import now writes to fhir_resources AND materialises structured rows in all four native tables in a single call; adds materialised counts to the return value and audit log entry - Add postCreate hooks to Condition, MedicationRequest, AllergyIntolerance in fhir/resources/index.js so direct FHIR API writes also materialise natively - 6 new unit tests (15 total passing) covering all four resource types, edge cases (no value, code.text fallback, wrong resourceType skip) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cfb18da commit ab49683

7 files changed

Lines changed: 798 additions & 56 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
-- =============================================================================
2+
-- 007_clinical_detail.sql
3+
-- Native relational tables for patient conditions, medications, and allergies.
4+
--
5+
-- Prior to this migration these resource types were imported from Epic and
6+
-- other EHR sources but only stored as opaque JSON in fhir_resources. They
7+
-- are now also materialised into structured rows so that:
8+
-- - the inactivation risk engine can inspect active problem lists and
9+
-- nephrotoxic / immunosuppressive medication lists
10+
-- - CDS Hooks services can query conditions and allergies without parsing
11+
-- JSONB blobs at query time
12+
-- - OPTN export templates can reference structured clinical context
13+
--
14+
-- Sources: FHIR_R4 (Epic import, direct FHIR POST), HL7_V2, MANUAL entry.
15+
-- All tables follow the same org-scoped, RLS-protected pattern as 002_clinical.
16+
-- =============================================================================
17+
18+
-- ---------------------------------------------------------------------------
19+
-- patient_conditions
20+
-- Materialised from FHIR R4 Condition resources. code/system follow SNOMED-CT
21+
-- or ICD-10-CM as sent by the originating EHR. clinical_status mirrors the
22+
-- FHIR valueset: active | recurrence | relapse | inactive | remission |
23+
-- resolved. category mirrors: problem-list-item | encounter-diagnosis.
24+
-- ---------------------------------------------------------------------------
25+
CREATE TABLE patient_conditions (
26+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
27+
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
28+
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
29+
code TEXT NOT NULL,
30+
code_system TEXT,
31+
display TEXT,
32+
clinical_status TEXT,
33+
verification_status TEXT,
34+
category TEXT,
35+
onset_date DATE,
36+
abatement_date DATE,
37+
notes TEXT,
38+
source TEXT NOT NULL DEFAULT 'MANUAL'
39+
CHECK (source IN ('MANUAL', 'FHIR_R4', 'HL7_V2')),
40+
fhir_resource_id TEXT,
41+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
42+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
43+
);
44+
45+
CREATE INDEX idx_conditions_patient ON patient_conditions(org_id, patient_id, onset_date DESC);
46+
CREATE INDEX idx_conditions_code ON patient_conditions(org_id, patient_id, code);
47+
CREATE INDEX idx_conditions_status ON patient_conditions(org_id, patient_id, clinical_status);
48+
49+
CREATE TRIGGER patient_conditions_updated BEFORE UPDATE ON patient_conditions
50+
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
51+
52+
-- ---------------------------------------------------------------------------
53+
-- patient_medications
54+
-- Materialised from FHIR R4 MedicationRequest resources. medication_code /
55+
-- code_system follow RxNorm (system urn:oid:2.16.840.1.113883.6.88) as
56+
-- returned by Epic. status mirrors the FHIR MedicationRequest.status
57+
-- valueset: active | on-hold | cancelled | completed | stopped | draft.
58+
-- ---------------------------------------------------------------------------
59+
CREATE TABLE patient_medications (
60+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
61+
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
62+
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
63+
medication_code TEXT,
64+
code_system TEXT,
65+
medication_name TEXT NOT NULL,
66+
status TEXT,
67+
intent TEXT,
68+
dosage_text TEXT,
69+
frequency TEXT,
70+
route TEXT,
71+
authored_on DATE,
72+
prescriber TEXT,
73+
notes TEXT,
74+
source TEXT NOT NULL DEFAULT 'MANUAL'
75+
CHECK (source IN ('MANUAL', 'FHIR_R4', 'HL7_V2')),
76+
fhir_resource_id TEXT,
77+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
78+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
79+
);
80+
81+
CREATE INDEX idx_medications_patient ON patient_medications(org_id, patient_id, authored_on DESC);
82+
CREATE INDEX idx_medications_name ON patient_medications(org_id, patient_id, medication_name);
83+
CREATE INDEX idx_medications_status ON patient_medications(org_id, patient_id, status);
84+
85+
CREATE TRIGGER patient_medications_updated BEFORE UPDATE ON patient_medications
86+
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
87+
88+
-- ---------------------------------------------------------------------------
89+
-- patient_allergies
90+
-- Materialised from FHIR R4 AllergyIntolerance resources. criticality mirrors
91+
-- the FHIR valueset: low | high | unable-to-assess. allergy_type: allergy |
92+
-- intolerance. category: food | medication | environment | biologic.
93+
-- ---------------------------------------------------------------------------
94+
CREATE TABLE patient_allergies (
95+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
96+
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
97+
patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
98+
code TEXT,
99+
code_system TEXT,
100+
display TEXT NOT NULL,
101+
allergy_type TEXT,
102+
category TEXT,
103+
criticality TEXT,
104+
clinical_status TEXT,
105+
verification_status TEXT,
106+
reaction_description TEXT,
107+
onset_date DATE,
108+
notes TEXT,
109+
source TEXT NOT NULL DEFAULT 'MANUAL'
110+
CHECK (source IN ('MANUAL', 'FHIR_R4', 'HL7_V2')),
111+
fhir_resource_id TEXT,
112+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
113+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
114+
);
115+
116+
CREATE INDEX idx_allergies_patient ON patient_allergies(org_id, patient_id);
117+
CREATE INDEX idx_allergies_code ON patient_allergies(org_id, patient_id, code);
118+
CREATE INDEX idx_allergies_criticality ON patient_allergies(org_id, patient_id, criticality);
119+
120+
CREATE TRIGGER patient_allergies_updated BEFORE UPDATE ON patient_allergies
121+
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
122+
123+
-- ---------------------------------------------------------------------------
124+
-- Row-level security — same pattern as every other tenant table
125+
-- ---------------------------------------------------------------------------
126+
DO $$
127+
DECLARE
128+
tbl TEXT;
129+
tables TEXT[] := ARRAY['patient_conditions', 'patient_medications', 'patient_allergies'];
130+
BEGIN
131+
FOREACH tbl IN ARRAY tables LOOP
132+
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);
133+
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tbl);
134+
EXECUTE format(
135+
'CREATE POLICY %I ON %I USING (org_id = app_current_org_id()) '
136+
'WITH CHECK (org_id = app_current_org_id())',
137+
'tenant_isolation_' || tbl, tbl
138+
);
139+
END LOOP;
140+
END
141+
$$;
142+
143+
-- =============================================================================
144+
-- 007_clinical_detail.sql complete
145+
-- =============================================================================

server/src/fhir/resources/index.js

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,29 @@
1717
*/
1818

1919
const { errors } = require('../../util/errors');
20-
const patientService = require('../../services/patientService');
21-
const labResultService = require('../../services/labResultService');
20+
const patientService = require('../../services/patientService');
21+
const labResultService = require('../../services/labResultService');
22+
const conditionService = require('../../services/conditionService');
23+
const medicationService = require('../../services/medicationService');
24+
const allergyService = require('../../services/allergyService');
25+
26+
/**
27+
* Resolve a FHIR subject/patient reference to a native patients row.
28+
* Returns null if the patient cannot be found so postCreate hooks can
29+
* gracefully skip native materialisation rather than throwing.
30+
*/
31+
async function resolveNativePatient(client, ctx, subjectRef) {
32+
const fhirPatientId = (subjectRef || '').replace(/^Patient\//, '');
33+
if (!fhirPatientId) return null;
34+
const res = await client.query(
35+
`SELECT body FROM fhir_resources
36+
WHERE org_id = $1 AND resource_type = 'Patient' AND resource_id = $2`,
37+
[ctx.orgId, fhirPatientId],
38+
);
39+
const mrn = res.rows[0]?.body?.identifier?.[0]?.value;
40+
if (!mrn) return null;
41+
return patientService.getByMrn(client, ctx, mrn);
42+
}
2243

2344
function requireField(obj, path, label) {
2445
const segs = path.split('.');
@@ -122,13 +143,57 @@ const MedicationRequest = {
122143
expectType(body, 'MedicationRequest');
123144
if (!body.subject?.reference) throw errors.badRequest('subject.reference required');
124145
},
146+
async postCreate(client, ctx, body) {
147+
const native = await resolveNativePatient(client, ctx, body.subject?.reference);
148+
if (!native) return;
149+
const medCC = body.medicationCodeableConcept;
150+
const coding = medCC?.coding?.[0];
151+
const dosage = (body.dosageInstruction || [])[0];
152+
await medicationService.create(client, ctx, {
153+
patient_id: native.id,
154+
medication_code: coding?.code || null,
155+
code_system: coding?.system || null,
156+
medication_name: coding?.display || medCC?.text || 'Unknown medication',
157+
status: body.status || null,
158+
intent: body.intent || null,
159+
dosage_text: dosage?.text || null,
160+
frequency: dosage?.timing?.code?.text || null,
161+
route: dosage?.route?.coding?.[0]?.display || dosage?.route?.text || null,
162+
authored_on: body.authoredOn?.substring(0, 10) || null,
163+
prescriber: body.requester?.display || null,
164+
source: 'FHIR_R4',
165+
fhir_resource_id: body.id || null,
166+
});
167+
},
125168
};
126169

127170
const AllergyIntolerance = {
128171
validate(body) {
129172
expectType(body, 'AllergyIntolerance');
130173
if (!body.patient?.reference) throw errors.badRequest('patient.reference required');
131174
},
175+
async postCreate(client, ctx, body) {
176+
const native = await resolveNativePatient(client, ctx, body.patient?.reference);
177+
if (!native) return;
178+
const coding = body.code?.coding?.[0];
179+
const display = coding?.display || body.code?.text || 'Unknown allergen';
180+
await allergyService.create(client, ctx, {
181+
patient_id: native.id,
182+
code: coding?.code || null,
183+
code_system: coding?.system || null,
184+
display,
185+
allergy_type: body.type || null,
186+
category: Array.isArray(body.category) ? body.category[0] : null,
187+
criticality: body.criticality || null,
188+
clinical_status: body.clinicalStatus?.coding?.[0]?.code || null,
189+
verification_status: body.verificationStatus?.coding?.[0]?.code || null,
190+
reaction_description: body.reaction?.[0]?.description ||
191+
body.reaction?.[0]?.manifestation?.[0]?.text || null,
192+
onset_date: body.onsetDateTime?.substring(0, 10) || body.onsetDate || null,
193+
source: 'FHIR_R4',
194+
fhir_resource_id: body.id || null,
195+
});
196+
},
132197
};
133198

134199
// ============================================================================
@@ -158,6 +223,25 @@ const Condition = {
158223
throw errors.badRequest('code.coding or code.text required');
159224
}
160225
},
226+
async postCreate(client, ctx, body) {
227+
const native = await resolveNativePatient(client, ctx, body.subject?.reference);
228+
if (!native) return;
229+
const coding = body.code?.coding?.[0];
230+
const display = coding?.display || body.code?.text || 'Unknown condition';
231+
await conditionService.create(client, ctx, {
232+
patient_id: native.id,
233+
code: coding?.code || display,
234+
code_system: coding?.system || null,
235+
display,
236+
clinical_status: body.clinicalStatus?.coding?.[0]?.code || null,
237+
verification_status: body.verificationStatus?.coding?.[0]?.code || null,
238+
category: body.category?.[0]?.coding?.[0]?.code || null,
239+
onset_date: body.onsetDateTime?.substring(0, 10) || body.onsetDate || null,
240+
abatement_date: body.abatementDateTime?.substring(0, 10) || body.abatementDate || null,
241+
source: 'FHIR_R4',
242+
fhir_resource_id: body.id || null,
243+
});
244+
},
161245
};
162246

163247
const Coverage = {

0 commit comments

Comments
 (0)