From 1dc6fded27eeae861dd68ef69509a94e09906e08 Mon Sep 17 00:00:00 2001 From: NeuroKoder3 Date: Sat, 23 May 2026 14:33:42 -0500 Subject: [PATCH 1/3] feat(epic): materialise Observation/Condition/MedicationRequest/AllergyIntolerance into native tables - 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 --- .../src/db/migrations/007_clinical_detail.sql | 145 ++++++++++ server/src/fhir/resources/index.js | 88 +++++- server/src/integrations/epic/importPatient.js | 271 ++++++++++++++---- server/src/services/allergyService.js | 52 ++++ server/src/services/conditionService.js | 58 ++++ server/src/services/medicationService.js | 58 ++++ server/test/unit/epicIntegration.test.mjs | 182 ++++++++++++ 7 files changed, 798 insertions(+), 56 deletions(-) create mode 100644 server/src/db/migrations/007_clinical_detail.sql create mode 100644 server/src/services/allergyService.js create mode 100644 server/src/services/conditionService.js create mode 100644 server/src/services/medicationService.js diff --git a/server/src/db/migrations/007_clinical_detail.sql b/server/src/db/migrations/007_clinical_detail.sql new file mode 100644 index 0000000..bf96f2b --- /dev/null +++ b/server/src/db/migrations/007_clinical_detail.sql @@ -0,0 +1,145 @@ +-- ============================================================================= +-- 007_clinical_detail.sql +-- Native relational tables for patient conditions, medications, and allergies. +-- +-- Prior to this migration these resource types were imported from Epic and +-- other EHR sources but only stored as opaque JSON in fhir_resources. They +-- are now also materialised into structured rows so that: +-- - the inactivation risk engine can inspect active problem lists and +-- nephrotoxic / immunosuppressive medication lists +-- - CDS Hooks services can query conditions and allergies without parsing +-- JSONB blobs at query time +-- - OPTN export templates can reference structured clinical context +-- +-- Sources: FHIR_R4 (Epic import, direct FHIR POST), HL7_V2, MANUAL entry. +-- All tables follow the same org-scoped, RLS-protected pattern as 002_clinical. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- patient_conditions +-- Materialised from FHIR R4 Condition resources. code/system follow SNOMED-CT +-- or ICD-10-CM as sent by the originating EHR. clinical_status mirrors the +-- FHIR valueset: active | recurrence | relapse | inactive | remission | +-- resolved. category mirrors: problem-list-item | encounter-diagnosis. +-- --------------------------------------------------------------------------- +CREATE TABLE patient_conditions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, + code TEXT NOT NULL, + code_system TEXT, + display TEXT, + clinical_status TEXT, + verification_status TEXT, + category TEXT, + onset_date DATE, + abatement_date DATE, + notes TEXT, + source TEXT NOT NULL DEFAULT 'MANUAL' + CHECK (source IN ('MANUAL', 'FHIR_R4', 'HL7_V2')), + fhir_resource_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_conditions_patient ON patient_conditions(org_id, patient_id, onset_date DESC); +CREATE INDEX idx_conditions_code ON patient_conditions(org_id, patient_id, code); +CREATE INDEX idx_conditions_status ON patient_conditions(org_id, patient_id, clinical_status); + +CREATE TRIGGER patient_conditions_updated BEFORE UPDATE ON patient_conditions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- patient_medications +-- Materialised from FHIR R4 MedicationRequest resources. medication_code / +-- code_system follow RxNorm (system urn:oid:2.16.840.1.113883.6.88) as +-- returned by Epic. status mirrors the FHIR MedicationRequest.status +-- valueset: active | on-hold | cancelled | completed | stopped | draft. +-- --------------------------------------------------------------------------- +CREATE TABLE patient_medications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, + medication_code TEXT, + code_system TEXT, + medication_name TEXT NOT NULL, + status TEXT, + intent TEXT, + dosage_text TEXT, + frequency TEXT, + route TEXT, + authored_on DATE, + prescriber TEXT, + notes TEXT, + source TEXT NOT NULL DEFAULT 'MANUAL' + CHECK (source IN ('MANUAL', 'FHIR_R4', 'HL7_V2')), + fhir_resource_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_medications_patient ON patient_medications(org_id, patient_id, authored_on DESC); +CREATE INDEX idx_medications_name ON patient_medications(org_id, patient_id, medication_name); +CREATE INDEX idx_medications_status ON patient_medications(org_id, patient_id, status); + +CREATE TRIGGER patient_medications_updated BEFORE UPDATE ON patient_medications + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- patient_allergies +-- Materialised from FHIR R4 AllergyIntolerance resources. criticality mirrors +-- the FHIR valueset: low | high | unable-to-assess. allergy_type: allergy | +-- intolerance. category: food | medication | environment | biologic. +-- --------------------------------------------------------------------------- +CREATE TABLE patient_allergies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE, + code TEXT, + code_system TEXT, + display TEXT NOT NULL, + allergy_type TEXT, + category TEXT, + criticality TEXT, + clinical_status TEXT, + verification_status TEXT, + reaction_description TEXT, + onset_date DATE, + notes TEXT, + source TEXT NOT NULL DEFAULT 'MANUAL' + CHECK (source IN ('MANUAL', 'FHIR_R4', 'HL7_V2')), + fhir_resource_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_allergies_patient ON patient_allergies(org_id, patient_id); +CREATE INDEX idx_allergies_code ON patient_allergies(org_id, patient_id, code); +CREATE INDEX idx_allergies_criticality ON patient_allergies(org_id, patient_id, criticality); + +CREATE TRIGGER patient_allergies_updated BEFORE UPDATE ON patient_allergies + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- Row-level security — same pattern as every other tenant table +-- --------------------------------------------------------------------------- +DO $$ +DECLARE + tbl TEXT; + tables TEXT[] := ARRAY['patient_conditions', 'patient_medications', 'patient_allergies']; +BEGIN + FOREACH tbl IN ARRAY tables LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tbl); + EXECUTE format( + 'CREATE POLICY %I ON %I USING (org_id = app_current_org_id()) ' + 'WITH CHECK (org_id = app_current_org_id())', + 'tenant_isolation_' || tbl, tbl + ); + END LOOP; +END +$$; + +-- ============================================================================= +-- 007_clinical_detail.sql complete +-- ============================================================================= diff --git a/server/src/fhir/resources/index.js b/server/src/fhir/resources/index.js index e6fd532..121985d 100644 --- a/server/src/fhir/resources/index.js +++ b/server/src/fhir/resources/index.js @@ -17,8 +17,29 @@ */ const { errors } = require('../../util/errors'); -const patientService = require('../../services/patientService'); -const labResultService = require('../../services/labResultService'); +const patientService = require('../../services/patientService'); +const labResultService = require('../../services/labResultService'); +const conditionService = require('../../services/conditionService'); +const medicationService = require('../../services/medicationService'); +const allergyService = require('../../services/allergyService'); + +/** + * Resolve a FHIR subject/patient reference to a native patients row. + * Returns null if the patient cannot be found so postCreate hooks can + * gracefully skip native materialisation rather than throwing. + */ +async function resolveNativePatient(client, ctx, subjectRef) { + const fhirPatientId = (subjectRef || '').replace(/^Patient\//, ''); + if (!fhirPatientId) return null; + const res = await client.query( + `SELECT body FROM fhir_resources + WHERE org_id = $1 AND resource_type = 'Patient' AND resource_id = $2`, + [ctx.orgId, fhirPatientId], + ); + const mrn = res.rows[0]?.body?.identifier?.[0]?.value; + if (!mrn) return null; + return patientService.getByMrn(client, ctx, mrn); +} function requireField(obj, path, label) { const segs = path.split('.'); @@ -122,6 +143,28 @@ const MedicationRequest = { expectType(body, 'MedicationRequest'); if (!body.subject?.reference) throw errors.badRequest('subject.reference required'); }, + async postCreate(client, ctx, body) { + const native = await resolveNativePatient(client, ctx, body.subject?.reference); + if (!native) return; + const medCC = body.medicationCodeableConcept; + const coding = medCC?.coding?.[0]; + const dosage = (body.dosageInstruction || [])[0]; + await medicationService.create(client, ctx, { + patient_id: native.id, + medication_code: coding?.code || null, + code_system: coding?.system || null, + medication_name: coding?.display || medCC?.text || 'Unknown medication', + status: body.status || null, + intent: body.intent || null, + dosage_text: dosage?.text || null, + frequency: dosage?.timing?.code?.text || null, + route: dosage?.route?.coding?.[0]?.display || dosage?.route?.text || null, + authored_on: body.authoredOn?.substring(0, 10) || null, + prescriber: body.requester?.display || null, + source: 'FHIR_R4', + fhir_resource_id: body.id || null, + }); + }, }; const AllergyIntolerance = { @@ -129,6 +172,28 @@ const AllergyIntolerance = { expectType(body, 'AllergyIntolerance'); if (!body.patient?.reference) throw errors.badRequest('patient.reference required'); }, + async postCreate(client, ctx, body) { + const native = await resolveNativePatient(client, ctx, body.patient?.reference); + if (!native) return; + const coding = body.code?.coding?.[0]; + const display = coding?.display || body.code?.text || 'Unknown allergen'; + await allergyService.create(client, ctx, { + patient_id: native.id, + code: coding?.code || null, + code_system: coding?.system || null, + display, + allergy_type: body.type || null, + category: Array.isArray(body.category) ? body.category[0] : null, + criticality: body.criticality || null, + clinical_status: body.clinicalStatus?.coding?.[0]?.code || null, + verification_status: body.verificationStatus?.coding?.[0]?.code || null, + reaction_description: body.reaction?.[0]?.description || + body.reaction?.[0]?.manifestation?.[0]?.text || null, + onset_date: body.onsetDateTime?.substring(0, 10) || body.onsetDate || null, + source: 'FHIR_R4', + fhir_resource_id: body.id || null, + }); + }, }; // ============================================================================ @@ -158,6 +223,25 @@ const Condition = { throw errors.badRequest('code.coding or code.text required'); } }, + async postCreate(client, ctx, body) { + const native = await resolveNativePatient(client, ctx, body.subject?.reference); + if (!native) return; + const coding = body.code?.coding?.[0]; + const display = coding?.display || body.code?.text || 'Unknown condition'; + await conditionService.create(client, ctx, { + patient_id: native.id, + code: coding?.code || display, + code_system: coding?.system || null, + display, + clinical_status: body.clinicalStatus?.coding?.[0]?.code || null, + verification_status: body.verificationStatus?.coding?.[0]?.code || null, + category: body.category?.[0]?.coding?.[0]?.code || null, + onset_date: body.onsetDateTime?.substring(0, 10) || body.onsetDate || null, + abatement_date: body.abatementDateTime?.substring(0, 10) || body.abatementDate || null, + source: 'FHIR_R4', + fhir_resource_id: body.id || null, + }); + }, }; const Coverage = { diff --git a/server/src/integrations/epic/importPatient.js b/server/src/integrations/epic/importPatient.js index d352fb5..5074b58 100644 --- a/server/src/integrations/epic/importPatient.js +++ b/server/src/integrations/epic/importPatient.js @@ -22,22 +22,38 @@ * - FHIR resources are written to `fhir_resources` via fhir/storage.create. * Resource ids are namespaced as `epic-` to avoid colliding * with native FHIR rows. + * - Clinical resources are ALSO materialised into native structured tables: + * Observation → lab_results + * Condition → patient_conditions + * MedicationRequest → patient_medications + * AllergyIntolerance → patient_allergies * - One audit log entry per import call: action `integration.epic.import`. * * Returns: * { * patient, // the TransTrack patients row (post-upsert) * created, // boolean - true if a new patient was inserted - * stored: { // counts of FHIR resources persisted + * stored: { // counts of FHIR resources persisted (fhir_resources) * observations, conditions, medicationRequests, allergies, * }, + * materialised: { // counts written into native structured tables + * labResults, conditions, medications, allergies, + * }, * scopeGranted, // scope string from Epic, if available * } */ -const patientService = require('../../services/patientService'); -const audit = require('../../services/auditService'); -const fhirStorage = require('../../fhir/storage'); +const patientService = require('../../services/patientService'); +const labResultService = require('../../services/labResultService'); +const conditionService = require('../../services/conditionService'); +const medicationService = require('../../services/medicationService'); +const allergyService = require('../../services/allergyService'); +const audit = require('../../services/auditService'); +const fhirStorage = require('../../fhir/storage'); + +// --------------------------------------------------------------------------- +// Patient normalisation helpers +// --------------------------------------------------------------------------- function pickName(patient) { const n = @@ -85,11 +101,11 @@ function pickEmail(patient) { function mapGender(g) { switch ((g || '').toLowerCase()) { - case 'male': return 'M'; - case 'female': return 'F'; - case 'other': return 'O'; + case 'male': return 'M'; + case 'female': return 'F'; + case 'other': return 'O'; case 'unknown': return 'U'; - default: return null; + default: return null; } } @@ -121,13 +137,13 @@ async function persistPatient(client, ctx, patient) { const existing = await patientService.getByMrn(client, ctx, native.mrn); if (existing) { const updated = await patientService.update(client, ctx, existing.id, { - first_name: native.first_name || existing.first_name, - last_name: native.last_name || existing.last_name, - middle_name: native.middle_name || existing.middle_name, + first_name: native.first_name || existing.first_name, + last_name: native.last_name || existing.last_name, + middle_name: native.middle_name || existing.middle_name, date_of_birth: native.date_of_birth || existing.date_of_birth, - sex: native.sex || existing.sex, - phone: native.phone || existing.phone, - email: native.email || existing.email, + sex: native.sex || existing.sex, + phone: native.phone || existing.phone, + email: native.email || existing.email, }); return { row: updated || existing, created: false }; } @@ -135,17 +151,168 @@ async function persistPatient(client, ctx, patient) { return { row, created: true }; } -async function persistFhirCollection(client, ctx, type, resources) { - let n = 0; - for (const r of resources || []) { - if (!r || r.resourceType !== type) continue; - const namespacedId = r.id ? `epic-${r.id}` : undefined; - await fhirStorage.create(client, ctx, type, { ...r, id: namespacedId }); - n += 1; +// --------------------------------------------------------------------------- +// Helpers — store to fhir_resources AND materialise into native tables +// --------------------------------------------------------------------------- + +function namespacedId(resource) { + return resource?.id ? `epic-${resource.id}` : undefined; +} + +async function storeToFhir(client, ctx, type, resource) { + const id = namespacedId(resource); + await fhirStorage.create(client, ctx, type, { ...resource, id }); + return id; +} + +/** + * Observations → fhir_resources + lab_results + */ +async function persistObservations(client, ctx, patientId, observations) { + let fhirStored = 0; + let nativeMaterialised = 0; + + for (const obs of observations || []) { + if (!obs || obs.resourceType !== 'Observation') continue; + await storeToFhir(client, ctx, 'Observation', obs); + fhirStored++; + + const coding = obs.code?.coding?.[0]; + const value = obs.valueQuantity + ? `${obs.valueQuantity.value}` + : obs.valueString != null + ? obs.valueString + : obs.valueCodeableConcept?.text ?? null; + + if (coding && value != null) { + await labResultService.create(client, ctx, { + patient_id: patientId, + test_code: coding.code, + test_name: coding.display || coding.code, + value, + units: obs.valueQuantity?.unit || null, + reference_range: (obs.referenceRange || [])[0]?.text || null, + result_status: obs.status || null, + collected_at: obs.effectiveDateTime || new Date().toISOString(), + resulted_at: obs.issued || null, + source: 'FHIR_R4', + }); + nativeMaterialised++; + } } - return n; + return { fhirStored, nativeMaterialised }; } +/** + * Conditions → fhir_resources + patient_conditions + */ +async function persistConditions(client, ctx, patientId, conditions) { + let fhirStored = 0; + let nativeMaterialised = 0; + + for (const cond of conditions || []) { + if (!cond || cond.resourceType !== 'Condition') continue; + const fhirId = await storeToFhir(client, ctx, 'Condition', cond); + fhirStored++; + + const coding = cond.code?.coding?.[0]; + const display = coding?.display || cond.code?.text || 'Unknown condition'; + + await conditionService.create(client, ctx, { + patient_id: patientId, + code: coding?.code || display, + code_system: coding?.system || null, + display, + clinical_status: cond.clinicalStatus?.coding?.[0]?.code || null, + verification_status: cond.verificationStatus?.coding?.[0]?.code || null, + category: cond.category?.[0]?.coding?.[0]?.code || null, + onset_date: cond.onsetDateTime?.substring(0, 10) || cond.onsetDate || null, + abatement_date: cond.abatementDateTime?.substring(0, 10) || cond.abatementDate || null, + source: 'FHIR_R4', + fhir_resource_id: fhirId || null, + }); + nativeMaterialised++; + } + return { fhirStored, nativeMaterialised }; +} + +/** + * MedicationRequests → fhir_resources + patient_medications + */ +async function persistMedicationRequests(client, ctx, patientId, medicationRequests) { + let fhirStored = 0; + let nativeMaterialised = 0; + + for (const med of medicationRequests || []) { + if (!med || med.resourceType !== 'MedicationRequest') continue; + const fhirId = await storeToFhir(client, ctx, 'MedicationRequest', med); + fhirStored++; + + const medCC = med.medicationCodeableConcept; + const coding = medCC?.coding?.[0]; + const medicationName = coding?.display || medCC?.text || 'Unknown medication'; + const dosage = (med.dosageInstruction || [])[0]; + + await medicationService.create(client, ctx, { + patient_id: patientId, + medication_code: coding?.code || null, + code_system: coding?.system || null, + medication_name: medicationName, + status: med.status || null, + intent: med.intent || null, + dosage_text: dosage?.text || null, + frequency: dosage?.timing?.code?.text || null, + route: dosage?.route?.coding?.[0]?.display || dosage?.route?.text || null, + authored_on: med.authoredOn?.substring(0, 10) || null, + prescriber: med.requester?.display || null, + source: 'FHIR_R4', + fhir_resource_id: fhirId || null, + }); + nativeMaterialised++; + } + return { fhirStored, nativeMaterialised }; +} + +/** + * AllergyIntolerances → fhir_resources + patient_allergies + */ +async function persistAllergies(client, ctx, patientId, allergies) { + let fhirStored = 0; + let nativeMaterialised = 0; + + for (const allergy of allergies || []) { + if (!allergy || allergy.resourceType !== 'AllergyIntolerance') continue; + const fhirId = await storeToFhir(client, ctx, 'AllergyIntolerance', allergy); + fhirStored++; + + const coding = allergy.code?.coding?.[0]; + const display = coding?.display || allergy.code?.text || 'Unknown allergen'; + + await allergyService.create(client, ctx, { + patient_id: patientId, + code: coding?.code || null, + code_system: coding?.system || null, + display, + allergy_type: allergy.type || null, + category: Array.isArray(allergy.category) ? allergy.category[0] : null, + criticality: allergy.criticality || null, + clinical_status: allergy.clinicalStatus?.coding?.[0]?.code || null, + verification_status: allergy.verificationStatus?.coding?.[0]?.code || null, + reaction_description: allergy.reaction?.[0]?.description || + allergy.reaction?.[0]?.manifestation?.[0]?.text || null, + onset_date: allergy.onsetDateTime?.substring(0, 10) || allergy.onsetDate || null, + source: 'FHIR_R4', + fhir_resource_id: fhirId || null, + }); + nativeMaterialised++; + } + return { fhirStored, nativeMaterialised }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + /** * Bundle mode - persist a pre-fetched Epic bundle. */ @@ -153,43 +320,38 @@ async function importPatientFromBundle(client, ctx, bundle) { if (!bundle?.patient) { throw new Error('importPatientFromBundle: bundle.patient is required'); } - const { row: patient, created } = await persistPatient( - client, - ctx, - bundle.patient, - ); + const { row: patient, created } = await persistPatient(client, ctx, bundle.patient); - // Persist Patient FHIR resource as well so SMART/CDS clients can read it. + // Persist Patient FHIR resource so SMART/CDS clients can read it. const patientFhirId = `epic-${bundle.patient.id}`; await fhirStorage.create(client, ctx, 'Patient', { ...bundle.patient, id: patientFhirId, extension: [ ...(bundle.patient.extension || []), - { - url: 'urn:transtrack:source-system', - valueString: 'epic-on-fhir-sandbox', - }, - { - url: 'urn:transtrack:native-patient-id', - valueString: patient.id, - }, + { url: 'urn:transtrack:source-system', valueString: 'epic-on-fhir-sandbox' }, + { url: 'urn:transtrack:native-patient-id', valueString: patient.id }, ], }); + const [obsResult, condResult, medResult, allergyResult] = await Promise.all([ + persistObservations(client, ctx, patient.id, bundle.observations), + persistConditions(client, ctx, patient.id, bundle.conditions), + persistMedicationRequests(client, ctx, patient.id, bundle.medicationRequests), + persistAllergies(client, ctx, patient.id, bundle.allergies), + ]); + const stored = { - observations: await persistFhirCollection( - client, ctx, 'Observation', bundle.observations, - ), - conditions: await persistFhirCollection( - client, ctx, 'Condition', bundle.conditions, - ), - medicationRequests: await persistFhirCollection( - client, ctx, 'MedicationRequest', bundle.medicationRequests, - ), - allergies: await persistFhirCollection( - client, ctx, 'AllergyIntolerance', bundle.allergies, - ), + observations: obsResult.fhirStored, + conditions: condResult.fhirStored, + medicationRequests: medResult.fhirStored, + allergies: allergyResult.fhirStored, + }; + const materialised = { + labResults: obsResult.nativeMaterialised, + conditions: condResult.nativeMaterialised, + medications: medResult.nativeMaterialised, + allergies: allergyResult.nativeMaterialised, }; await audit.record(client, ctx, { @@ -202,17 +364,13 @@ async function importPatientFromBundle(client, ctx, bundle) { created, mrn: patient.mrn, stored, + materialised, scope_granted: bundle.scopeGranted || null, source: 'epic-on-fhir', }, }); - return { - patient, - created, - stored, - scopeGranted: bundle.scopeGranted || null, - }; + return { patient, created, stored, materialised, scopeGranted: bundle.scopeGranted || null }; } /** @@ -235,4 +393,9 @@ module.exports = { pickName, importPatientFromBundle, importPatientFromEpic, + // exported for testing + persistObservations, + persistConditions, + persistMedicationRequests, + persistAllergies, }; diff --git a/server/src/services/allergyService.js b/server/src/services/allergyService.js new file mode 100644 index 0000000..d7569ff --- /dev/null +++ b/server/src/services/allergyService.js @@ -0,0 +1,52 @@ +'use strict'; + +const audit = require('./auditService'); + +const COLS = [ + 'id', 'org_id', 'patient_id', 'code', 'code_system', 'display', + 'allergy_type', 'category', 'criticality', 'clinical_status', + 'verification_status', 'reaction_description', 'onset_date', + 'notes', 'source', 'fhir_resource_id', + 'created_at', 'updated_at', +]; + +async function listForPatient(client, ctx, patientId, { limit = 100 } = {}) { + const r = await client.query( + `SELECT ${COLS.join(',')} FROM patient_allergies + WHERE org_id = $1 AND patient_id = $2 + ORDER BY criticality DESC NULLS LAST, created_at DESC + LIMIT $3`, + [ctx.orgId, patientId, limit], + ); + return r.rows; +} + +async function create(client, ctx, input) { + const cols = ['org_id']; + const vals = [ctx.orgId]; + for (const k of Object.keys(input)) { + if (COLS.includes(k) && k !== 'id' && k !== 'org_id') { + cols.push(k); + vals.push(input[k]); + } + } + const ph = vals.map((_, i) => `$${i + 1}`).join(','); + const r = await client.query( + `INSERT INTO patient_allergies (${cols.join(',')}) VALUES (${ph}) + RETURNING ${COLS.join(',')}`, + vals, + ); + await audit.record(client, ctx, { + action: 'allergy.create', + entityType: 'patient_allergy', + entityId: r.rows[0].id, + details: { + display: r.rows[0].display, + criticality: r.rows[0].criticality, + source: r.rows[0].source, + }, + }); + return r.rows[0]; +} + +module.exports = { listForPatient, create, COLS }; diff --git a/server/src/services/conditionService.js b/server/src/services/conditionService.js new file mode 100644 index 0000000..ede4f74 --- /dev/null +++ b/server/src/services/conditionService.js @@ -0,0 +1,58 @@ +'use strict'; + +const audit = require('./auditService'); + +const COLS = [ + 'id', 'org_id', 'patient_id', 'code', 'code_system', 'display', + 'clinical_status', 'verification_status', 'category', + 'onset_date', 'abatement_date', 'notes', 'source', 'fhir_resource_id', + 'created_at', 'updated_at', +]; + +async function listForPatient(client, ctx, patientId, { limit = 100, clinicalStatus } = {}) { + const params = [ctx.orgId, patientId]; + let where = 'org_id = $1 AND patient_id = $2'; + if (clinicalStatus) { + params.push(clinicalStatus); + where += ` AND clinical_status = $${params.length}`; + } + params.push(limit); + const r = await client.query( + `SELECT ${COLS.join(',')} FROM patient_conditions + WHERE ${where} + ORDER BY onset_date DESC NULLS LAST, created_at DESC + LIMIT $${params.length}`, + params, + ); + return r.rows; +} + +async function create(client, ctx, input) { + const cols = ['org_id']; + const vals = [ctx.orgId]; + for (const k of Object.keys(input)) { + if (COLS.includes(k) && k !== 'id' && k !== 'org_id') { + cols.push(k); + vals.push(input[k]); + } + } + const ph = vals.map((_, i) => `$${i + 1}`).join(','); + const r = await client.query( + `INSERT INTO patient_conditions (${cols.join(',')}) VALUES (${ph}) + RETURNING ${COLS.join(',')}`, + vals, + ); + await audit.record(client, ctx, { + action: 'condition.create', + entityType: 'patient_condition', + entityId: r.rows[0].id, + details: { + code: r.rows[0].code, + display: r.rows[0].display, + source: r.rows[0].source, + }, + }); + return r.rows[0]; +} + +module.exports = { listForPatient, create, COLS }; diff --git a/server/src/services/medicationService.js b/server/src/services/medicationService.js new file mode 100644 index 0000000..1e3ca0e --- /dev/null +++ b/server/src/services/medicationService.js @@ -0,0 +1,58 @@ +'use strict'; + +const audit = require('./auditService'); + +const COLS = [ + 'id', 'org_id', 'patient_id', 'medication_code', 'code_system', + 'medication_name', 'status', 'intent', 'dosage_text', 'frequency', + 'route', 'authored_on', 'prescriber', 'notes', 'source', 'fhir_resource_id', + 'created_at', 'updated_at', +]; + +async function listForPatient(client, ctx, patientId, { limit = 100, status } = {}) { + const params = [ctx.orgId, patientId]; + let where = 'org_id = $1 AND patient_id = $2'; + if (status) { + params.push(status); + where += ` AND status = $${params.length}`; + } + params.push(limit); + const r = await client.query( + `SELECT ${COLS.join(',')} FROM patient_medications + WHERE ${where} + ORDER BY authored_on DESC NULLS LAST, created_at DESC + LIMIT $${params.length}`, + params, + ); + return r.rows; +} + +async function create(client, ctx, input) { + const cols = ['org_id']; + const vals = [ctx.orgId]; + for (const k of Object.keys(input)) { + if (COLS.includes(k) && k !== 'id' && k !== 'org_id') { + cols.push(k); + vals.push(input[k]); + } + } + const ph = vals.map((_, i) => `$${i + 1}`).join(','); + const r = await client.query( + `INSERT INTO patient_medications (${cols.join(',')}) VALUES (${ph}) + RETURNING ${COLS.join(',')}`, + vals, + ); + await audit.record(client, ctx, { + action: 'medication.create', + entityType: 'patient_medication', + entityId: r.rows[0].id, + details: { + medication_name: r.rows[0].medication_name, + status: r.rows[0].status, + source: r.rows[0].source, + }, + }); + return r.rows[0]; +} + +module.exports = { listForPatient, create, COLS }; diff --git a/server/test/unit/epicIntegration.test.mjs b/server/test/unit/epicIntegration.test.mjs index d7f158a..21f6194 100644 --- a/server/test/unit/epicIntegration.test.mjs +++ b/server/test/unit/epicIntegration.test.mjs @@ -162,6 +162,188 @@ describe('Epic FHIR client (FHIR fetch)', () => { }); }); +// --------------------------------------------------------------------------- +// Clinical data materialisation +// --------------------------------------------------------------------------- + +describe('Epic clinical data materialisation', () => { + const importer = (() => { + const req = createRequire(import.meta.url); + return req('../../src/integrations/epic/importPatient.js'); + })(); + + function makeMockClient(insertedRows = {}) { + const queries = []; + const client = { + _queries: queries, + query: async (sql, params) => { + queries.push({ sql, params }); + // simulate fhir_resources Patient lookup returning nothing by default + if (sql.includes('fhir_resources')) return { rows: [] }; + const table = sql.match(/INSERT INTO (\w+)/)?.[1]; + const defaultRow = insertedRows[table] || { id: 'generated-uuid', ...Object.fromEntries((params || []).map((v, i) => [`col${i}`, v])) }; + return { rows: [defaultRow] }; + }, + }; + return client; + } + + const ctx = { orgId: 'org-1', userId: 'user-1' }; + const patientId = 'native-patient-uuid'; + + it('persistObservations: stores to fhir_resources and creates lab_results for valued obs', async () => { + const observations = [ + { + resourceType: 'Observation', + id: 'obs-1', + status: 'final', + code: { coding: [{ code: '2160-0', display: 'Creatinine [Mass/volume] in Serum or Plasma' }] }, + subject: { reference: 'Patient/epic-pt-1' }, + valueQuantity: { value: 1.2, unit: 'mg/dL' }, + effectiveDateTime: '2026-05-01T10:00:00Z', + issued: '2026-05-01T12:00:00Z', + referenceRange: [{ text: '0.7-1.3' }], + }, + { + resourceType: 'Observation', + id: 'obs-2', + status: 'final', + code: { coding: [{ code: 'panel' }] }, + subject: { reference: 'Patient/epic-pt-1' }, + // No value — should store to FHIR but not create lab_result + }, + ]; + const client = makeMockClient({ lab_results: { id: 'lr-1', test_code: '2160-0', source: 'FHIR_R4' } }); + const result = await importer.persistObservations(client, ctx, patientId, observations); + expect(result.fhirStored).toBe(2); + expect(result.nativeMaterialised).toBe(1); + const labInserts = client._queries.filter(q => q.sql.includes('INSERT INTO lab_results')); + expect(labInserts).toHaveLength(1); + expect(labInserts[0].params).toContain('2160-0'); + expect(labInserts[0].params).toContain('FHIR_R4'); + }); + + it('persistConditions: stores to fhir_resources and creates patient_conditions', async () => { + const conditions = [ + { + resourceType: 'Condition', + id: 'cond-1', + subject: { reference: 'Patient/epic-pt-1' }, + code: { + coding: [{ code: 'N18.5', system: 'http://hl7.org/fhir/sid/icd-10-cm', display: 'Chronic kidney disease, stage 5' }], + }, + clinicalStatus: { coding: [{ code: 'active' }] }, + verificationStatus: { coding: [{ code: 'confirmed' }] }, + category: [{ coding: [{ code: 'problem-list-item' }] }], + onsetDateTime: '2022-03-15T00:00:00Z', + }, + ]; + const client = makeMockClient({ + patient_conditions: { id: 'cond-uuid', code: 'N18.5', source: 'FHIR_R4' }, + }); + const result = await importer.persistConditions(client, ctx, patientId, conditions); + expect(result.fhirStored).toBe(1); + expect(result.nativeMaterialised).toBe(1); + const condInserts = client._queries.filter(q => q.sql.includes('INSERT INTO patient_conditions')); + expect(condInserts).toHaveLength(1); + expect(condInserts[0].params).toContain('N18.5'); + expect(condInserts[0].params).toContain('active'); + expect(condInserts[0].params).toContain('FHIR_R4'); + expect(condInserts[0].params).toContain('2022-03-15'); + }); + + it('persistMedicationRequests: stores to fhir_resources and creates patient_medications', async () => { + const medicationRequests = [ + { + resourceType: 'MedicationRequest', + id: 'med-1', + status: 'active', + intent: 'order', + subject: { reference: 'Patient/epic-pt-1' }, + medicationCodeableConcept: { + coding: [{ code: '197361', system: 'http://www.nlm.nih.gov/research/umls/rxnorm', display: 'Tacrolimus 1 MG Oral Capsule' }], + }, + authoredOn: '2026-04-01', + requester: { display: 'Dr. Smith' }, + dosageInstruction: [{ text: '1 mg twice daily', route: { coding: [{ display: 'Oral' }] } }], + }, + ]; + const client = makeMockClient({ + patient_medications: { id: 'med-uuid', medication_name: 'Tacrolimus 1 MG Oral Capsule', source: 'FHIR_R4' }, + }); + const result = await importer.persistMedicationRequests(client, ctx, patientId, medicationRequests); + expect(result.fhirStored).toBe(1); + expect(result.nativeMaterialised).toBe(1); + const medInserts = client._queries.filter(q => q.sql.includes('INSERT INTO patient_medications')); + expect(medInserts).toHaveLength(1); + expect(medInserts[0].params).toContain('Tacrolimus 1 MG Oral Capsule'); + expect(medInserts[0].params).toContain('active'); + expect(medInserts[0].params).toContain('Dr. Smith'); + expect(medInserts[0].params).toContain('FHIR_R4'); + }); + + it('persistAllergies: stores to fhir_resources and creates patient_allergies', async () => { + const allergies = [ + { + resourceType: 'AllergyIntolerance', + id: 'allergy-1', + type: 'allergy', + category: ['medication'], + criticality: 'high', + patient: { reference: 'Patient/epic-pt-1' }, + code: { + coding: [{ code: '7980', system: 'http://www.nlm.nih.gov/research/umls/rxnorm', display: 'Penicillin' }], + }, + clinicalStatus: { coding: [{ code: 'active' }] }, + verificationStatus: { coding: [{ code: 'confirmed' }] }, + reaction: [{ description: 'Anaphylaxis' }], + onsetDateTime: '2010-06-01T00:00:00Z', + }, + ]; + const client = makeMockClient({ + patient_allergies: { id: 'allergy-uuid', display: 'Penicillin', source: 'FHIR_R4' }, + }); + const result = await importer.persistAllergies(client, ctx, patientId, allergies); + expect(result.fhirStored).toBe(1); + expect(result.nativeMaterialised).toBe(1); + const allergyInserts = client._queries.filter(q => q.sql.includes('INSERT INTO patient_allergies')); + expect(allergyInserts).toHaveLength(1); + expect(allergyInserts[0].params).toContain('Penicillin'); + expect(allergyInserts[0].params).toContain('high'); + expect(allergyInserts[0].params).toContain('medication'); + expect(allergyInserts[0].params).toContain('Anaphylaxis'); + expect(allergyInserts[0].params).toContain('FHIR_R4'); + }); + + it('skips resources with wrong resourceType without throwing', async () => { + const client = makeMockClient(); + const r1 = await importer.persistConditions(client, ctx, patientId, [{ resourceType: 'Observation' }]); + const r2 = await importer.persistMedicationRequests(client, ctx, patientId, [null, undefined]); + const r3 = await importer.persistAllergies(client, ctx, patientId, []); + expect(r1.fhirStored).toBe(0); + expect(r2.fhirStored).toBe(0); + expect(r3.fhirStored).toBe(0); + }); + + it('persistConditions: uses code.text when coding array is absent', async () => { + const conditions = [ + { + resourceType: 'Condition', + id: 'cond-text', + subject: { reference: 'Patient/p' }, + code: { text: 'End-stage renal disease' }, + }, + ]; + const client = makeMockClient({ + patient_conditions: { id: 'c-uuid', code: 'End-stage renal disease', source: 'FHIR_R4' }, + }); + const result = await importer.persistConditions(client, ctx, patientId, conditions); + expect(result.nativeMaterialised).toBe(1); + const condInserts = client._queries.filter(q => q.sql.includes('INSERT INTO patient_conditions')); + expect(condInserts[0].params).toContain('End-stage renal disease'); + }); +}); + describe('Epic patient normalization', () => { it('extracts MRN from a typed identifier and maps gender to TransTrack codes', () => { const patient = { From e81ebbcf7dbdbfca2dbf8eebdcc5f11899e0c5c7 Mon Sep 17 00:00:00 2001 From: NeuroKoder3 Date: Sat, 23 May 2026 22:47:31 -0500 Subject: [PATCH 2/3] chore(epic): verify sandbox end-to-end, update default scopes and client docs - Confirmed token exchange against Epic May 2026 sandbox (Non-Production Client ID a8634931-c997-4516-90cd-21ec3a27813e) - JWKS hosted at gist.githubusercontent.com/NeuroKoder3/.../raw/jwks.json - All 5 core system scopes granted and verified: Patient, Observation, Condition, MedicationRequest, AllergyIntolerance - Trim DEFAULT_SCOPES to the confirmed-granted set; Encounter, Immunization, Organization, Procedure registered in app and will be re-added once propagated - Add scripts/epic-sandbox-check.mjs: full diagnostic (token, scopes, metadata, patient bundle fetch, native table materialisation preview) - Add scripts/epic-key-check.mjs: keypair consistency validator Co-authored-by: Cursor --- scripts/epic-key-check.mjs | 108 ++++++++++++++++ scripts/epic-sandbox-check.mjs | 172 +++++++++++++++++++++++++ server/src/integrations/epic/client.js | 18 +-- 3 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 scripts/epic-key-check.mjs create mode 100644 scripts/epic-sandbox-check.mjs diff --git a/scripts/epic-key-check.mjs b/scripts/epic-key-check.mjs new file mode 100644 index 0000000..a7cd773 --- /dev/null +++ b/scripts/epic-key-check.mjs @@ -0,0 +1,108 @@ +/** + * Verifies the private key and JWKS are a matching pair, + * and checks the JWT assertion that would be sent to Epic. + */ +import { createSign, createVerify, createPublicKey } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const privateKeyPath = path.join(__dirname, '..', 'epic-keys', 'transtrack-epic-private.pem'); +const jwksPath = path.join(__dirname, '..', 'epic-keys', 'jwks.json'); + +console.log('\n\x1b[1mEpic Key Pair Diagnostic\x1b[0m\n'); + +// ── 1. Load private key ──────────────────────────────────────────────────── +let privPem; +try { + privPem = readFileSync(privateKeyPath, 'utf8'); + console.log('✓ Private key file loaded'); +} catch(e) { + console.log('✗ Cannot read private key:', e.message); + process.exit(1); +} + +// ── 2. Derive public key from private key ───────────────────────────────── +let derivedPub; +try { + const privKeyObj = createPublicKey({ key: privPem, format: 'pem' }); + derivedPub = privKeyObj.export({ type: 'spki', format: 'pem' }); + console.log('✓ Public key derived from private key'); +} catch(e) { + console.log('✗ Private key is invalid / unreadable:', e.message); + process.exit(1); +} + +// ── 3. Load JWKS and extract n+e ────────────────────────────────────────── +let jwks; +try { + jwks = JSON.parse(readFileSync(jwksPath, 'utf8')); + console.log('✓ JWKS file loaded'); + const k = jwks.keys[0]; + console.log(` kid : ${k.kid}`); + console.log(` alg : ${k.alg}`); + console.log(` use : ${k.use}`); + console.log(` n : ${k.n.substring(0,32)}…`); +} catch(e) { + console.log('✗ Cannot read JWKS:', e.message); + process.exit(1); +} + +// ── 4. Verify keypair matches by sign+verify ────────────────────────────── +try { + const testData = 'transtrack-epic-keypair-check'; + const signer = createSign('RSA-SHA384'); + signer.update(testData); + const sig = signer.sign(privPem); + + const verifier = createVerify('RSA-SHA384'); + verifier.update(testData); + const ok = verifier.verify(derivedPub, sig); + + if (ok) { + console.log('✓ Private key signs correctly — keypair is internally consistent'); + } else { + console.log('✗ Signature verification failed — key may be corrupted'); + } +} catch(e) { + console.log('✗ Sign/verify error:', e.message); +} + +// ── 5. Reconstruct JWK n value from private key and compare ────────────── +try { + const pubKey = createPublicKey({ key: privPem, format: 'pem' }); + const jwkFromPrivate = pubKey.export({ format: 'jwk' }); + const nFromPrivate = jwkFromPrivate.n; + const nFromJwks = jwks.keys[0].n; + + if (nFromPrivate === nFromJwks) { + console.log('✓ JWKS n value MATCHES the private key — correct keypair registered'); + } else { + console.log('✗ JWKS n value DOES NOT MATCH the private key'); + console.log(' This is the cause of invalid_client: Epic has a different public key.'); + console.log(' You need to either:'); + console.log(' (a) Re-register the JWKS in your Epic app with this file\'s public key, OR'); + console.log(' (b) Replace epic-keys/ with the keypair that IS registered in Epic'); + console.log(`\n n from private key : ${nFromPrivate.substring(0,48)}…`); + console.log(` n from jwks.json : ${nFromJwks.substring(0,48)}…`); + } +} catch(e) { + console.log('✗ JWK comparison error:', e.message); +} + +// ── 6. Check kid matches ────────────────────────────────────────────────── +const kidInJwks = jwks.keys[0]?.kid; +const kidInCode = 'transtrack-epic-1'; +console.log(`\n kid in jwks.json : ${kidInJwks}`); +console.log(` kid used in JWT : ${kidInCode}`); +if (kidInJwks === kidInCode) { + console.log('✓ kid values match'); +} else { + console.log('✗ kid MISMATCH — Epic will reject the JWT because kid does not match any registered key'); +} + +// ── 7. Print the full public key for copy-paste into Epic ───────────────── +console.log('\n\x1b[1mPublic key (for pasting into Epic app registration if needed):\x1b[0m'); +console.log(derivedPub); diff --git a/scripts/epic-sandbox-check.mjs b/scripts/epic-sandbox-check.mjs new file mode 100644 index 0000000..4b246b4 --- /dev/null +++ b/scripts/epic-sandbox-check.mjs @@ -0,0 +1,172 @@ +/** + * Epic sandbox diagnostic script. + * Checks: token auth, granted scopes, and a live patient bundle fetch. + * Usage: node scripts/epic-sandbox-check.mjs + */ +import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const require = createRequire(import.meta.url); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const { createEpicClientFromKeyFile, DEFAULT_SCOPES } = require('../server/src/integrations/epic/client.js'); + +const CLIENT_ID = 'a8634931-c997-4516-90cd-21ec3a27813e'; +const KEY_FILE = path.join(__dirname, '..', 'epic-keys', 'transtrack-epic-private.pem'); +const TEST_PATIENT = 'erXuFYUfucBZaryVksYEcMg3'; // Epic sandbox: Camila Maria Lopez + +const REQUESTED_SCOPES = DEFAULT_SCOPES.split(' '); + +function tick(label) { process.stdout.write(`\n \x1b[32m✓\x1b[0m ${label}`); } +function fail(label) { process.stdout.write(`\n \x1b[31m✗\x1b[0m ${label}`); } +function info(label) { process.stdout.write(`\n \x1b[36m·\x1b[0m ${label}`); } +function section(title) { console.log(`\n\x1b[1m\x1b[34m── ${title} ──\x1b[0m`); } + +async function run() { + console.log('\n\x1b[1mTransTrack × Epic Sandbox Diagnostic\x1b[0m'); + console.log(` Client ID : ${CLIENT_ID}`); + console.log(` Key file : ${KEY_FILE}`); + + // ── 1. TOKEN EXCHANGE ────────────────────────────────────────────────────── + section('1 · Token Exchange (SMART Backend Services)'); + let client; + let tok; + try { + client = createEpicClientFromKeyFile({ + clientId: CLIENT_ID, + privateKeyFile: KEY_FILE, + }); + tok = await client.getAccessToken(); + tick(`Access token obtained (expires in ~${Math.round((tok.expiresAt - Date.now()) / 1000)}s)`); + tick(`Token type: ${tok.tokenType}`); + } catch (err) { + fail(`Token exchange FAILED: ${err.message}`); + process.exit(1); + } + + // ── 2. SCOPE CHECK ───────────────────────────────────────────────────────── + section('2 · Scope Check'); + const grantedScopes = (tok.scope || '').split(/\s+/).filter(Boolean); + info(`Scopes granted by Epic (${grantedScopes.length}):`); + for (const s of grantedScopes) { + process.stdout.write(`\n \x1b[32m${s}\x1b[0m`); + } + + console.log('\n'); + info(`Scopes requested by TransTrack code (${REQUESTED_SCOPES.length}):`); + const missing = []; + for (const s of REQUESTED_SCOPES) { + if (grantedScopes.includes(s)) { + process.stdout.write(`\n \x1b[32m✓ ${s}\x1b[0m`); + } else { + process.stdout.write(`\n \x1b[31m✗ ${s} ← NOT GRANTED\x1b[0m`); + missing.push(s); + } + } + console.log('\n'); + if (missing.length === 0) { + tick('All requested scopes are granted'); + } else { + fail(`${missing.length} requested scope(s) NOT granted — these resources will fail at import`); + } + + // ── 3. FHIR METADATA ─────────────────────────────────────────────────────── + section('3 · FHIR Server Metadata'); + try { + const meta = await client.fhirGet('metadata'); + tick(`FHIR version : ${meta.fhirVersion}`); + tick(`Software : ${meta.software?.name || 'unknown'} ${meta.software?.version || ''}`); + const supportedResources = (meta.rest?.[0]?.resource || []).map(r => r.type); + tick(`Resources supported: ${supportedResources.length}`); + const ourResources = ['Patient','Observation','Condition','MedicationRequest','AllergyIntolerance']; + for (const r of ourResources) { + if (supportedResources.includes(r)) { + process.stdout.write(`\n \x1b[32m✓ ${r}\x1b[0m`); + } else { + process.stdout.write(`\n \x1b[31m✗ ${r} ← not in CapabilityStatement\x1b[0m`); + } + } + console.log(''); + } catch (err) { + fail(`Metadata fetch failed: ${err.message}`); + } + + // ── 4. LIVE PATIENT BUNDLE FETCH ─────────────────────────────────────────── + section(`4 · Patient Bundle Fetch (Camila Lopez · ${TEST_PATIENT})`); + let bundle; + try { + bundle = await client.fetchPatientBundle(TEST_PATIENT); + tick(`Patient : ${bundle.patient?.name?.[0]?.given?.join(' ')} ${bundle.patient?.name?.[0]?.family}`); + tick(`DOB : ${bundle.patient?.birthDate}`); + tick(`Gender : ${bundle.patient?.gender}`); + tick(`MRN : ${bundle.patient?.identifier?.find(i => i.type?.coding?.[0]?.code === 'MR')?.value || bundle.patient?.identifier?.[0]?.value || 'none'}`); + console.log(''); + info(`Observations (labs) : ${bundle.observations.length}`); + info(`Conditions (problem list) : ${bundle.conditions.length}`); + info(`Medication requests : ${bundle.medicationRequests.length}`); + info(`Allergy intolerances : ${bundle.allergies.length}`); + info(`Scope granted : ${bundle.scopeGranted}`); + } catch (err) { + fail(`Patient bundle fetch FAILED: ${err.message}`); + process.exit(1); + } + + // ── 5. NATIVE TABLE PREVIEW ──────────────────────────────────────────────── + section('5 · Native Table Materialisation Preview (dry run — no DB)'); + + // Observations → lab_results + console.log('\n \x1b[1mObservations → lab_results\x1b[0m'); + let labCount = 0; + for (const obs of bundle.observations.slice(0, 5)) { + const coding = obs.code?.coding?.[0]; + const value = obs.valueQuantity + ? `${obs.valueQuantity.value} ${obs.valueQuantity.unit || ''}`.trim() + : obs.valueString ?? obs.valueCodeableConcept?.text ?? null; + if (coding && value != null) { + info(`${coding.display || coding.code} → ${value} (${obs.effectiveDateTime?.substring(0,10) || '?'})`); + labCount++; + } + } + if (bundle.observations.length > 5) info(`… and ${bundle.observations.length - 5} more`); + + // Conditions → patient_conditions + console.log('\n \x1b[1mConditions → patient_conditions\x1b[0m'); + for (const c of bundle.conditions.slice(0, 5)) { + const display = c.code?.coding?.[0]?.display || c.code?.text || 'unknown'; + const status = c.clinicalStatus?.coding?.[0]?.code || '?'; + info(`${display} [${status}]`); + } + if (bundle.conditions.length > 5) info(`… and ${bundle.conditions.length - 5} more`); + + // MedicationRequests → patient_medications + console.log('\n \x1b[1mMedicationRequests → patient_medications\x1b[0m'); + for (const m of bundle.medicationRequests.slice(0, 5)) { + const name = m.medicationCodeableConcept?.coding?.[0]?.display || m.medicationCodeableConcept?.text || 'unknown'; + const status = m.status || '?'; + info(`${name} [${status}]`); + } + if (bundle.medicationRequests.length > 5) info(`… and ${bundle.medicationRequests.length - 5} more`); + + // Allergies → patient_allergies + console.log('\n \x1b[1mAllergyIntolerances → patient_allergies\x1b[0m'); + for (const a of bundle.allergies.slice(0, 5)) { + const display = a.code?.coding?.[0]?.display || a.code?.text || 'unknown'; + const criticality = a.criticality || '?'; + info(`${display} [criticality: ${criticality}]`); + } + if (bundle.allergies.length > 5) info(`… and ${bundle.allergies.length - 5} more`); + + // ── SUMMARY ──────────────────────────────────────────────────────────────── + section('Summary'); + tick('Token exchange OK'); + missing.length === 0 ? tick('All scopes granted OK') : fail(`Scopes missing: ${missing.join(', ')}`); + tick(`Patient fetch OK (${bundle.observations.length} obs, ${bundle.conditions.length} cond, ${bundle.medicationRequests.length} meds, ${bundle.allergies.length} allergies)`); + tick('Native table mapping Ready (run importPatientFromEpic to write to DB)'); + console.log('\n'); +} + +run().catch(err => { + console.error('\nFatal:', err.message); + process.exit(1); +}); diff --git a/server/src/integrations/epic/client.js b/server/src/integrations/epic/client.js index 180add8..1588fca 100644 --- a/server/src/integrations/epic/client.js +++ b/server/src/integrations/epic/client.js @@ -11,8 +11,11 @@ * Verified end-to-end against the Epic on FHIR Developer Sandbox * (https://fhir.epic.com) using the test patient "Camila Maria Lopez" * (Patient ID erXuFYUfucBZaryVksYEcMg3) with system-level scopes for - * Patient, Observation, Condition, MedicationRequest, AllergyIntolerance, - * Encounter, Immunization, Procedure, and Organization. + * Patient, Observation, Condition, MedicationRequest, and AllergyIntolerance. + * + * Sandbox app (Non-Production Client ID): a8634931-c997-4516-90cd-21ec3a27813e + * JWKS URI: https://gist.githubusercontent.com/NeuroKoder3/a2f2b23b69e49dd284b8147d6817bcaa/raw/jwks.json + * Verified: Epic May 2026 — token exchange confirmed, all 5 core scopes granted. */ const { createSign, randomUUID } = require('node:crypto'); @@ -24,20 +27,17 @@ const DEFAULT_FHIR_BASE = 'https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4'; /** - * Minimal default scope set known to be granted by the Epic non-production - * sandbox for a "Backend Systems" application with all USCDI-core read - * APIs enabled. + * Core scopes confirmed granted by the Epic non-production sandbox for a + * Backend Systems app. Encounter, Immunization, Organization, and Procedure + * are registered in the app but may take additional time to propagate; add + * them back once confirmed granted. */ const DEFAULT_SCOPES = [ 'system/AllergyIntolerance.read', 'system/Condition.read', - 'system/Encounter.read', - 'system/Immunization.read', 'system/MedicationRequest.read', 'system/Observation.read', - 'system/Organization.read', 'system/Patient.read', - 'system/Procedure.read', ].join(' '); function b64url(buf) { From f84aa156d710f1724c98c8a88ebeafc95a958509 Mon Sep 17 00:00:00 2001 From: NeuroKoder3 Date: Sun, 24 May 2026 11:52:57 -0500 Subject: [PATCH 3/3] =?UTF-8?q?chore(epic):=20restore=20all=209=20scopes?= =?UTF-8?q?=20=C2=97=20fully=20confirmed=20granted=20by=20Epic=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- server/src/integrations/epic/client.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/src/integrations/epic/client.js b/server/src/integrations/epic/client.js index 1588fca..6cfdf78 100644 --- a/server/src/integrations/epic/client.js +++ b/server/src/integrations/epic/client.js @@ -27,17 +27,20 @@ const DEFAULT_FHIR_BASE = 'https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4'; /** - * Core scopes confirmed granted by the Epic non-production sandbox for a - * Backend Systems app. Encounter, Immunization, Organization, and Procedure - * are registered in the app but may take additional time to propagate; add - * them back once confirmed granted. + * Full system-level scope set for the TransTrack Backend Services app. + * All 9 scopes are registered in the Epic non-production sandbox app + * (Client ID a8634931-c997-4516-90cd-21ec3a27813e). */ const DEFAULT_SCOPES = [ 'system/AllergyIntolerance.read', 'system/Condition.read', + 'system/Encounter.read', + 'system/Immunization.read', 'system/MedicationRequest.read', 'system/Observation.read', + 'system/Organization.read', 'system/Patient.read', + 'system/Procedure.read', ].join(' '); function b64url(buf) {