@erosolaraijs/cure 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/bin/cure.d.ts +10 -0
- package/dist/bin/cure.d.ts.map +1 -0
- package/dist/bin/cure.js +169 -0
- package/dist/bin/cure.js.map +1 -0
- package/dist/capabilities/cancerTreatmentCapability.d.ts +167 -0
- package/dist/capabilities/cancerTreatmentCapability.d.ts.map +1 -0
- package/dist/capabilities/cancerTreatmentCapability.js +912 -0
- package/dist/capabilities/cancerTreatmentCapability.js.map +1 -0
- package/dist/capabilities/index.d.ts +2 -0
- package/dist/capabilities/index.d.ts.map +1 -0
- package/dist/capabilities/index.js +3 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/compliance/hipaa.d.ts +337 -0
- package/dist/compliance/hipaa.d.ts.map +1 -0
- package/dist/compliance/hipaa.js +929 -0
- package/dist/compliance/hipaa.js.map +1 -0
- package/dist/examples/cancerTreatmentDemo.d.ts +21 -0
- package/dist/examples/cancerTreatmentDemo.d.ts.map +1 -0
- package/dist/examples/cancerTreatmentDemo.js +216 -0
- package/dist/examples/cancerTreatmentDemo.js.map +1 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.d.ts +265 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.d.ts.map +1 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.js +808 -0
- package/dist/integrations/clinicalTrials/clinicalTrialsGov.js.map +1 -0
- package/dist/integrations/ehr/fhir.d.ts +455 -0
- package/dist/integrations/ehr/fhir.d.ts.map +1 -0
- package/dist/integrations/ehr/fhir.js +859 -0
- package/dist/integrations/ehr/fhir.js.map +1 -0
- package/dist/integrations/genomics/genomicPlatforms.d.ts +362 -0
- package/dist/integrations/genomics/genomicPlatforms.d.ts.map +1 -0
- package/dist/integrations/genomics/genomicPlatforms.js +1079 -0
- package/dist/integrations/genomics/genomicPlatforms.js.map +1 -0
- package/package.json +52 -0
- package/src/bin/cure.ts +182 -0
- package/src/capabilities/cancerTreatmentCapability.ts +1161 -0
- package/src/capabilities/index.ts +2 -0
- package/src/compliance/hipaa.ts +1365 -0
- package/src/examples/cancerTreatmentDemo.ts +241 -0
- package/src/integrations/clinicalTrials/clinicalTrialsGov.ts +1143 -0
- package/src/integrations/ehr/fhir.ts +1304 -0
- package/src/integrations/genomics/genomicPlatforms.ts +1480 -0
- package/src/ml/outcomePredictor.ts +1301 -0
- package/src/safety/drugInteractions.ts +942 -0
- package/src/validation/retrospectiveValidator.ts +887 -0
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HL7 FHIR Integration Layer
|
|
3
|
+
*
|
|
4
|
+
* Connects to Electronic Health Record systems using the FHIR R4 standard.
|
|
5
|
+
* Supports Epic, Cerner, AllScripts, and other FHIR-compliant EHR systems.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: This module requires proper HIPAA compliance setup before use.
|
|
8
|
+
* All PHI access must be logged and patient consent must be verified.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from 'events';
|
|
12
|
+
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
14
|
+
// FHIR R4 TYPE DEFINITIONS
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
export interface FHIRConfig {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
clientId: string;
|
|
20
|
+
clientSecret?: string;
|
|
21
|
+
scopes: string[];
|
|
22
|
+
authType: 'smart-on-fhir' | 'basic' | 'oauth2' | 'client-credentials';
|
|
23
|
+
ehrVendor: 'epic' | 'cerner' | 'allscripts' | 'meditech' | 'generic';
|
|
24
|
+
timeout?: number;
|
|
25
|
+
retryAttempts?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FHIRPatient {
|
|
29
|
+
resourceType: 'Patient';
|
|
30
|
+
id: string;
|
|
31
|
+
identifier: FHIRIdentifier[];
|
|
32
|
+
name: FHIRHumanName[];
|
|
33
|
+
gender: 'male' | 'female' | 'other' | 'unknown';
|
|
34
|
+
birthDate: string;
|
|
35
|
+
address?: FHIRAddress[];
|
|
36
|
+
telecom?: FHIRContactPoint[];
|
|
37
|
+
maritalStatus?: FHIRCodeableConcept;
|
|
38
|
+
communication?: { language: FHIRCodeableConcept; preferred?: boolean }[];
|
|
39
|
+
extension?: FHIRExtension[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FHIRIdentifier {
|
|
43
|
+
system: string;
|
|
44
|
+
value: string;
|
|
45
|
+
type?: FHIRCodeableConcept;
|
|
46
|
+
use?: 'usual' | 'official' | 'temp' | 'secondary' | 'old';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FHIRHumanName {
|
|
50
|
+
use?: 'usual' | 'official' | 'temp' | 'nickname' | 'anonymous' | 'old' | 'maiden';
|
|
51
|
+
family?: string;
|
|
52
|
+
given?: string[];
|
|
53
|
+
prefix?: string[];
|
|
54
|
+
suffix?: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FHIRAddress {
|
|
58
|
+
use?: 'home' | 'work' | 'temp' | 'old' | 'billing';
|
|
59
|
+
type?: 'postal' | 'physical' | 'both';
|
|
60
|
+
line?: string[];
|
|
61
|
+
city?: string;
|
|
62
|
+
state?: string;
|
|
63
|
+
postalCode?: string;
|
|
64
|
+
country?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FHIRContactPoint {
|
|
68
|
+
system?: 'phone' | 'fax' | 'email' | 'pager' | 'url' | 'sms' | 'other';
|
|
69
|
+
value?: string;
|
|
70
|
+
use?: 'home' | 'work' | 'temp' | 'old' | 'mobile';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FHIRCodeableConcept {
|
|
74
|
+
coding?: FHIRCoding[];
|
|
75
|
+
text?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FHIRCoding {
|
|
79
|
+
system?: string;
|
|
80
|
+
version?: string;
|
|
81
|
+
code?: string;
|
|
82
|
+
display?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface FHIRExtension {
|
|
86
|
+
url: string;
|
|
87
|
+
valueString?: string;
|
|
88
|
+
valueCode?: string;
|
|
89
|
+
valueDecimal?: number;
|
|
90
|
+
valueBoolean?: boolean;
|
|
91
|
+
valueCoding?: FHIRCoding;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface FHIRCondition {
|
|
95
|
+
resourceType: 'Condition';
|
|
96
|
+
id: string;
|
|
97
|
+
clinicalStatus: FHIRCodeableConcept;
|
|
98
|
+
verificationStatus: FHIRCodeableConcept;
|
|
99
|
+
category: FHIRCodeableConcept[];
|
|
100
|
+
severity?: FHIRCodeableConcept;
|
|
101
|
+
code: FHIRCodeableConcept;
|
|
102
|
+
bodySite?: FHIRCodeableConcept[];
|
|
103
|
+
subject: FHIRReference;
|
|
104
|
+
onsetDateTime?: string;
|
|
105
|
+
recordedDate?: string;
|
|
106
|
+
stage?: {
|
|
107
|
+
summary?: FHIRCodeableConcept;
|
|
108
|
+
assessment?: FHIRReference[];
|
|
109
|
+
type?: FHIRCodeableConcept;
|
|
110
|
+
}[];
|
|
111
|
+
evidence?: {
|
|
112
|
+
code?: FHIRCodeableConcept[];
|
|
113
|
+
detail?: FHIRReference[];
|
|
114
|
+
}[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface FHIRObservation {
|
|
118
|
+
resourceType: 'Observation';
|
|
119
|
+
id: string;
|
|
120
|
+
status: 'registered' | 'preliminary' | 'final' | 'amended' | 'corrected' | 'cancelled' | 'entered-in-error' | 'unknown';
|
|
121
|
+
category?: FHIRCodeableConcept[];
|
|
122
|
+
code: FHIRCodeableConcept;
|
|
123
|
+
subject: FHIRReference;
|
|
124
|
+
effectiveDateTime?: string;
|
|
125
|
+
valueQuantity?: FHIRQuantity;
|
|
126
|
+
valueCodeableConcept?: FHIRCodeableConcept;
|
|
127
|
+
valueString?: string;
|
|
128
|
+
interpretation?: FHIRCodeableConcept[];
|
|
129
|
+
referenceRange?: {
|
|
130
|
+
low?: FHIRQuantity;
|
|
131
|
+
high?: FHIRQuantity;
|
|
132
|
+
type?: FHIRCodeableConcept;
|
|
133
|
+
text?: string;
|
|
134
|
+
}[];
|
|
135
|
+
component?: {
|
|
136
|
+
code: FHIRCodeableConcept;
|
|
137
|
+
valueQuantity?: FHIRQuantity;
|
|
138
|
+
valueCodeableConcept?: FHIRCodeableConcept;
|
|
139
|
+
valueString?: string;
|
|
140
|
+
}[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface FHIRQuantity {
|
|
144
|
+
value?: number;
|
|
145
|
+
comparator?: '<' | '<=' | '>=' | '>';
|
|
146
|
+
unit?: string;
|
|
147
|
+
system?: string;
|
|
148
|
+
code?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FHIRReference {
|
|
152
|
+
reference?: string;
|
|
153
|
+
type?: string;
|
|
154
|
+
identifier?: FHIRIdentifier;
|
|
155
|
+
display?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface FHIRMedicationRequest {
|
|
159
|
+
resourceType: 'MedicationRequest';
|
|
160
|
+
id: string;
|
|
161
|
+
status: 'active' | 'on-hold' | 'cancelled' | 'completed' | 'entered-in-error' | 'stopped' | 'draft' | 'unknown';
|
|
162
|
+
intent: 'proposal' | 'plan' | 'order' | 'original-order' | 'reflex-order' | 'filler-order' | 'instance-order' | 'option';
|
|
163
|
+
medicationCodeableConcept?: FHIRCodeableConcept;
|
|
164
|
+
medicationReference?: FHIRReference;
|
|
165
|
+
subject: FHIRReference;
|
|
166
|
+
authoredOn?: string;
|
|
167
|
+
requester?: FHIRReference;
|
|
168
|
+
dosageInstruction?: FHIRDosage[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FHIRDosage {
|
|
172
|
+
sequence?: number;
|
|
173
|
+
text?: string;
|
|
174
|
+
timing?: {
|
|
175
|
+
repeat?: {
|
|
176
|
+
frequency?: number;
|
|
177
|
+
period?: number;
|
|
178
|
+
periodUnit?: 's' | 'min' | 'h' | 'd' | 'wk' | 'mo' | 'a';
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
route?: FHIRCodeableConcept;
|
|
182
|
+
doseAndRate?: {
|
|
183
|
+
doseQuantity?: FHIRQuantity;
|
|
184
|
+
rateQuantity?: FHIRQuantity;
|
|
185
|
+
}[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface FHIRDiagnosticReport {
|
|
189
|
+
resourceType: 'DiagnosticReport';
|
|
190
|
+
id: string;
|
|
191
|
+
status: 'registered' | 'partial' | 'preliminary' | 'final' | 'amended' | 'corrected' | 'appended' | 'cancelled' | 'entered-in-error' | 'unknown';
|
|
192
|
+
category?: FHIRCodeableConcept[];
|
|
193
|
+
code: FHIRCodeableConcept;
|
|
194
|
+
subject: FHIRReference;
|
|
195
|
+
effectiveDateTime?: string;
|
|
196
|
+
issued?: string;
|
|
197
|
+
performer?: FHIRReference[];
|
|
198
|
+
result?: FHIRReference[];
|
|
199
|
+
conclusion?: string;
|
|
200
|
+
conclusionCode?: FHIRCodeableConcept[];
|
|
201
|
+
presentedForm?: {
|
|
202
|
+
contentType?: string;
|
|
203
|
+
data?: string;
|
|
204
|
+
url?: string;
|
|
205
|
+
title?: string;
|
|
206
|
+
}[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface FHIRProcedure {
|
|
210
|
+
resourceType: 'Procedure';
|
|
211
|
+
id: string;
|
|
212
|
+
status: 'preparation' | 'in-progress' | 'not-done' | 'on-hold' | 'stopped' | 'completed' | 'entered-in-error' | 'unknown';
|
|
213
|
+
code: FHIRCodeableConcept;
|
|
214
|
+
subject: FHIRReference;
|
|
215
|
+
performedDateTime?: string;
|
|
216
|
+
performedPeriod?: { start?: string; end?: string };
|
|
217
|
+
performer?: { actor: FHIRReference; function?: FHIRCodeableConcept }[];
|
|
218
|
+
bodySite?: FHIRCodeableConcept[];
|
|
219
|
+
outcome?: FHIRCodeableConcept;
|
|
220
|
+
report?: FHIRReference[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface FHIRBundle {
|
|
224
|
+
resourceType: 'Bundle';
|
|
225
|
+
id?: string;
|
|
226
|
+
type: 'document' | 'message' | 'transaction' | 'transaction-response' | 'batch' | 'batch-response' | 'history' | 'searchset' | 'collection';
|
|
227
|
+
total?: number;
|
|
228
|
+
link?: { relation: string; url: string }[];
|
|
229
|
+
entry?: {
|
|
230
|
+
fullUrl?: string;
|
|
231
|
+
resource?: FHIRResource;
|
|
232
|
+
search?: { mode?: 'match' | 'include' | 'outcome'; score?: number };
|
|
233
|
+
}[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type FHIRResource = FHIRPatient | FHIRCondition | FHIRObservation |
|
|
237
|
+
FHIRMedicationRequest | FHIRDiagnosticReport | FHIRProcedure | FHIRBundle;
|
|
238
|
+
|
|
239
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
240
|
+
// CANCER-SPECIFIC FHIR PROFILES
|
|
241
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
242
|
+
|
|
243
|
+
export interface CancerDiagnosis {
|
|
244
|
+
patientId: string;
|
|
245
|
+
conditionId: string;
|
|
246
|
+
cancerType: string;
|
|
247
|
+
icdCode: string;
|
|
248
|
+
snomedCode?: string;
|
|
249
|
+
histology?: string;
|
|
250
|
+
primarySite?: string;
|
|
251
|
+
laterality?: 'left' | 'right' | 'bilateral' | 'not-applicable';
|
|
252
|
+
stage?: {
|
|
253
|
+
system: 'ajcc' | 'figo' | 'rai' | 'binet' | 'iss' | 'other';
|
|
254
|
+
stage: string;
|
|
255
|
+
tnm?: { t?: string; n?: string; m?: string };
|
|
256
|
+
grade?: string;
|
|
257
|
+
};
|
|
258
|
+
diagnosisDate: Date;
|
|
259
|
+
verificationStatus: 'confirmed' | 'provisional' | 'differential' | 'refuted';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface CancerBiomarkers {
|
|
263
|
+
patientId: string;
|
|
264
|
+
collectionDate: Date;
|
|
265
|
+
biomarkers: {
|
|
266
|
+
name: string;
|
|
267
|
+
value: string | number;
|
|
268
|
+
unit?: string;
|
|
269
|
+
status: 'positive' | 'negative' | 'equivocal' | 'not-tested';
|
|
270
|
+
method?: string;
|
|
271
|
+
loincCode?: string;
|
|
272
|
+
}[];
|
|
273
|
+
genomicAlterations?: {
|
|
274
|
+
gene: string;
|
|
275
|
+
alteration: string;
|
|
276
|
+
type: 'mutation' | 'amplification' | 'deletion' | 'fusion' | 'rearrangement';
|
|
277
|
+
variantAlleleFrequency?: number;
|
|
278
|
+
pathogenicity?: 'pathogenic' | 'likely-pathogenic' | 'vus' | 'likely-benign' | 'benign';
|
|
279
|
+
}[];
|
|
280
|
+
tumorMutationalBurden?: {
|
|
281
|
+
value: number;
|
|
282
|
+
unit: 'mutations/Mb';
|
|
283
|
+
status: 'high' | 'intermediate' | 'low';
|
|
284
|
+
threshold?: number;
|
|
285
|
+
};
|
|
286
|
+
microsatelliteInstability?: {
|
|
287
|
+
status: 'MSI-H' | 'MSI-L' | 'MSS';
|
|
288
|
+
method: 'PCR' | 'NGS' | 'IHC';
|
|
289
|
+
};
|
|
290
|
+
pdl1Expression?: {
|
|
291
|
+
score: number;
|
|
292
|
+
scoreType: 'TPS' | 'CPS' | 'IC';
|
|
293
|
+
antibody?: string;
|
|
294
|
+
};
|
|
295
|
+
hrdStatus?: {
|
|
296
|
+
status: 'positive' | 'negative';
|
|
297
|
+
score?: number;
|
|
298
|
+
components?: {
|
|
299
|
+
loh?: number;
|
|
300
|
+
tai?: number;
|
|
301
|
+
lst?: number;
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface TreatmentHistory {
|
|
307
|
+
patientId: string;
|
|
308
|
+
treatments: {
|
|
309
|
+
id: string;
|
|
310
|
+
type: 'chemotherapy' | 'immunotherapy' | 'targeted-therapy' | 'radiation' | 'surgery' | 'hormone-therapy' | 'car-t' | 'other';
|
|
311
|
+
regimen?: string;
|
|
312
|
+
drugs?: { name: string; dose?: string; route?: string; rxNormCode?: string }[];
|
|
313
|
+
startDate: Date;
|
|
314
|
+
endDate?: Date;
|
|
315
|
+
status: 'planned' | 'active' | 'completed' | 'stopped' | 'on-hold';
|
|
316
|
+
cycles?: number;
|
|
317
|
+
response?: 'CR' | 'PR' | 'SD' | 'PD' | 'NE';
|
|
318
|
+
responseDate?: Date;
|
|
319
|
+
reasonStopped?: string;
|
|
320
|
+
adverseEvents?: { name: string; grade: number; ctcaeCode?: string }[];
|
|
321
|
+
}[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
325
|
+
// FHIR CLIENT IMPLEMENTATION
|
|
326
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
327
|
+
|
|
328
|
+
export class FHIRClient extends EventEmitter {
|
|
329
|
+
private config: FHIRConfig;
|
|
330
|
+
private accessToken?: string;
|
|
331
|
+
private tokenExpiry?: Date;
|
|
332
|
+
private auditLogger?: (event: AuditEvent) => Promise<void>;
|
|
333
|
+
|
|
334
|
+
constructor(config: FHIRConfig) {
|
|
335
|
+
super();
|
|
336
|
+
this.config = {
|
|
337
|
+
timeout: 30000,
|
|
338
|
+
retryAttempts: 3,
|
|
339
|
+
...config
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Set audit logger for HIPAA compliance
|
|
345
|
+
*/
|
|
346
|
+
setAuditLogger(logger: (event: AuditEvent) => Promise<void>): void {
|
|
347
|
+
this.auditLogger = logger;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Authenticate with the FHIR server
|
|
352
|
+
*/
|
|
353
|
+
async authenticate(): Promise<void> {
|
|
354
|
+
const startTime = Date.now();
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
switch (this.config.authType) {
|
|
358
|
+
case 'smart-on-fhir':
|
|
359
|
+
await this.smartOnFhirAuth();
|
|
360
|
+
break;
|
|
361
|
+
case 'client-credentials':
|
|
362
|
+
await this.clientCredentialsAuth();
|
|
363
|
+
break;
|
|
364
|
+
case 'oauth2':
|
|
365
|
+
await this.oauth2Auth();
|
|
366
|
+
break;
|
|
367
|
+
case 'basic':
|
|
368
|
+
// Basic auth doesn't require pre-authentication
|
|
369
|
+
break;
|
|
370
|
+
default:
|
|
371
|
+
throw new Error(`Unsupported auth type: ${this.config.authType}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await this.logAudit({
|
|
375
|
+
action: 'authenticate',
|
|
376
|
+
resourceType: 'System',
|
|
377
|
+
outcome: 'success',
|
|
378
|
+
duration: Date.now() - startTime
|
|
379
|
+
});
|
|
380
|
+
} catch (error) {
|
|
381
|
+
await this.logAudit({
|
|
382
|
+
action: 'authenticate',
|
|
383
|
+
resourceType: 'System',
|
|
384
|
+
outcome: 'failure',
|
|
385
|
+
duration: Date.now() - startTime,
|
|
386
|
+
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
|
387
|
+
});
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private async smartOnFhirAuth(): Promise<void> {
|
|
393
|
+
// SMART on FHIR authentication flow
|
|
394
|
+
// This is a simplified version - production would need full OAuth2 flow
|
|
395
|
+
const tokenEndpoint = await this.discoverTokenEndpoint();
|
|
396
|
+
|
|
397
|
+
const response = await this.httpRequest(tokenEndpoint, {
|
|
398
|
+
method: 'POST',
|
|
399
|
+
headers: {
|
|
400
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
401
|
+
},
|
|
402
|
+
body: new URLSearchParams({
|
|
403
|
+
grant_type: 'client_credentials',
|
|
404
|
+
client_id: this.config.clientId,
|
|
405
|
+
client_secret: this.config.clientSecret || '',
|
|
406
|
+
scope: this.config.scopes.join(' ')
|
|
407
|
+
}).toString()
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const data = JSON.parse(response);
|
|
411
|
+
this.accessToken = data.access_token;
|
|
412
|
+
this.tokenExpiry = new Date(Date.now() + (data.expires_in * 1000));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async clientCredentialsAuth(): Promise<void> {
|
|
416
|
+
const tokenEndpoint = `${this.config.baseUrl}/oauth2/token`;
|
|
417
|
+
|
|
418
|
+
const response = await this.httpRequest(tokenEndpoint, {
|
|
419
|
+
method: 'POST',
|
|
420
|
+
headers: {
|
|
421
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
422
|
+
'Authorization': `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64')}`
|
|
423
|
+
},
|
|
424
|
+
body: new URLSearchParams({
|
|
425
|
+
grant_type: 'client_credentials',
|
|
426
|
+
scope: this.config.scopes.join(' ')
|
|
427
|
+
}).toString()
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
const data = JSON.parse(response);
|
|
431
|
+
this.accessToken = data.access_token;
|
|
432
|
+
this.tokenExpiry = new Date(Date.now() + (data.expires_in * 1000));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async oauth2Auth(): Promise<void> {
|
|
436
|
+
// OAuth2 flow - similar to client credentials for server-to-server
|
|
437
|
+
await this.clientCredentialsAuth();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async discoverTokenEndpoint(): Promise<string> {
|
|
441
|
+
const wellKnown = `${this.config.baseUrl}/.well-known/smart-configuration`;
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const response = await this.httpRequest(wellKnown, { method: 'GET' });
|
|
445
|
+
const config = JSON.parse(response);
|
|
446
|
+
return config.token_endpoint;
|
|
447
|
+
} catch {
|
|
448
|
+
// Fall back to standard OAuth2 endpoint
|
|
449
|
+
return `${this.config.baseUrl}/oauth2/token`;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Get a patient by ID
|
|
455
|
+
*/
|
|
456
|
+
async getPatient(patientId: string): Promise<FHIRPatient> {
|
|
457
|
+
return await this.read<FHIRPatient>('Patient', patientId);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Search for patients
|
|
462
|
+
*/
|
|
463
|
+
async searchPatients(params: Record<string, string>): Promise<FHIRBundle> {
|
|
464
|
+
return await this.search('Patient', params);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Get cancer diagnosis for a patient
|
|
469
|
+
*/
|
|
470
|
+
async getCancerDiagnosis(patientId: string): Promise<CancerDiagnosis[]> {
|
|
471
|
+
const bundle = await this.search('Condition', {
|
|
472
|
+
patient: patientId,
|
|
473
|
+
category: 'encounter-diagnosis',
|
|
474
|
+
'code:below': '363346000' // SNOMED CT code for malignant neoplasm
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const diagnoses: CancerDiagnosis[] = [];
|
|
478
|
+
|
|
479
|
+
for (const entry of bundle.entry || []) {
|
|
480
|
+
const condition = entry.resource as FHIRCondition;
|
|
481
|
+
if (condition.resourceType === 'Condition') {
|
|
482
|
+
diagnoses.push(this.mapConditionToCancerDiagnosis(condition, patientId));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return diagnoses;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Get biomarkers and genomic data for a patient
|
|
491
|
+
*/
|
|
492
|
+
async getBiomarkers(patientId: string): Promise<CancerBiomarkers | null> {
|
|
493
|
+
// Get lab observations
|
|
494
|
+
const labBundle = await this.search('Observation', {
|
|
495
|
+
patient: patientId,
|
|
496
|
+
category: 'laboratory',
|
|
497
|
+
_sort: '-date',
|
|
498
|
+
_count: '100'
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Get genomic observations
|
|
502
|
+
const genomicBundle = await this.search('Observation', {
|
|
503
|
+
patient: patientId,
|
|
504
|
+
category: 'genomic-variant',
|
|
505
|
+
_sort: '-date',
|
|
506
|
+
_count: '100'
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Get diagnostic reports (for NGS results)
|
|
510
|
+
const reportBundle = await this.search('DiagnosticReport', {
|
|
511
|
+
patient: patientId,
|
|
512
|
+
category: 'GE', // Genetics
|
|
513
|
+
_sort: '-date',
|
|
514
|
+
_count: '50'
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
return this.aggregateBiomarkerData(patientId, labBundle, genomicBundle, reportBundle);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Get treatment history for a patient
|
|
522
|
+
*/
|
|
523
|
+
async getTreatmentHistory(patientId: string): Promise<TreatmentHistory> {
|
|
524
|
+
const [medications, procedures] = await Promise.all([
|
|
525
|
+
this.search('MedicationRequest', {
|
|
526
|
+
patient: patientId,
|
|
527
|
+
_sort: '-authoredon',
|
|
528
|
+
_count: '200'
|
|
529
|
+
}),
|
|
530
|
+
this.search('Procedure', {
|
|
531
|
+
patient: patientId,
|
|
532
|
+
_sort: '-date',
|
|
533
|
+
_count: '200'
|
|
534
|
+
})
|
|
535
|
+
]);
|
|
536
|
+
|
|
537
|
+
return this.aggregateTreatmentHistory(patientId, medications, procedures);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Get all observations for a patient
|
|
542
|
+
*/
|
|
543
|
+
async getObservations(patientId: string, category?: string): Promise<FHIRObservation[]> {
|
|
544
|
+
const params: Record<string, string> = {
|
|
545
|
+
patient: patientId,
|
|
546
|
+
_sort: '-date',
|
|
547
|
+
_count: '100'
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
if (category) {
|
|
551
|
+
params.category = category;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const bundle = await this.search('Observation', params);
|
|
555
|
+
return (bundle.entry || [])
|
|
556
|
+
.map(e => e.resource)
|
|
557
|
+
.filter((r): r is FHIRObservation => r?.resourceType === 'Observation');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Get all medications for a patient
|
|
562
|
+
*/
|
|
563
|
+
async getMedications(patientId: string): Promise<FHIRMedicationRequest[]> {
|
|
564
|
+
const bundle = await this.search('MedicationRequest', {
|
|
565
|
+
patient: patientId,
|
|
566
|
+
_sort: '-authoredon'
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
return (bundle.entry || [])
|
|
570
|
+
.map(e => e.resource)
|
|
571
|
+
.filter((r): r is FHIRMedicationRequest => r?.resourceType === 'MedicationRequest');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Get diagnostic reports for a patient
|
|
576
|
+
*/
|
|
577
|
+
async getDiagnosticReports(patientId: string): Promise<FHIRDiagnosticReport[]> {
|
|
578
|
+
const bundle = await this.search('DiagnosticReport', {
|
|
579
|
+
patient: patientId,
|
|
580
|
+
_sort: '-date'
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
return (bundle.entry || [])
|
|
584
|
+
.map(e => e.resource)
|
|
585
|
+
.filter((r): r is FHIRDiagnosticReport => r?.resourceType === 'DiagnosticReport');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Create a comprehensive cancer patient summary
|
|
590
|
+
*/
|
|
591
|
+
async getComprehensiveCancerSummary(patientId: string): Promise<{
|
|
592
|
+
patient: FHIRPatient;
|
|
593
|
+
diagnoses: CancerDiagnosis[];
|
|
594
|
+
biomarkers: CancerBiomarkers | null;
|
|
595
|
+
treatmentHistory: TreatmentHistory;
|
|
596
|
+
recentLabs: FHIRObservation[];
|
|
597
|
+
performanceStatus?: { score: number; scale: 'ECOG' | 'KPS'; date: Date };
|
|
598
|
+
}> {
|
|
599
|
+
const [patient, diagnoses, biomarkers, treatmentHistory, recentLabs] = await Promise.all([
|
|
600
|
+
this.getPatient(patientId),
|
|
601
|
+
this.getCancerDiagnosis(patientId),
|
|
602
|
+
this.getBiomarkers(patientId),
|
|
603
|
+
this.getTreatmentHistory(patientId),
|
|
604
|
+
this.getObservations(patientId, 'laboratory')
|
|
605
|
+
]);
|
|
606
|
+
|
|
607
|
+
// Get performance status (ECOG/KPS)
|
|
608
|
+
const performanceObs = await this.search('Observation', {
|
|
609
|
+
patient: patientId,
|
|
610
|
+
code: '89247-1', // LOINC for ECOG performance status
|
|
611
|
+
_sort: '-date',
|
|
612
|
+
_count: '1'
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
let performanceStatus: { score: number; scale: 'ECOG' | 'KPS'; date: Date } | undefined;
|
|
616
|
+
const perfEntry = performanceObs.entry?.[0]?.resource as FHIRObservation;
|
|
617
|
+
if (perfEntry?.valueQuantity?.value !== undefined) {
|
|
618
|
+
performanceStatus = {
|
|
619
|
+
score: perfEntry.valueQuantity.value,
|
|
620
|
+
scale: 'ECOG',
|
|
621
|
+
date: new Date(perfEntry.effectiveDateTime || Date.now())
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
patient,
|
|
627
|
+
diagnoses,
|
|
628
|
+
biomarkers,
|
|
629
|
+
treatmentHistory,
|
|
630
|
+
recentLabs: recentLabs.slice(0, 20),
|
|
631
|
+
performanceStatus
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
636
|
+
// CORE FHIR OPERATIONS
|
|
637
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Read a resource by ID
|
|
641
|
+
*/
|
|
642
|
+
async read<T extends FHIRResource>(resourceType: string, id: string): Promise<T> {
|
|
643
|
+
const url = `${this.config.baseUrl}/${resourceType}/${id}`;
|
|
644
|
+
const startTime = Date.now();
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
const response = await this.httpRequest(url, {
|
|
648
|
+
method: 'GET',
|
|
649
|
+
headers: await this.getAuthHeaders()
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
await this.logAudit({
|
|
653
|
+
action: 'read',
|
|
654
|
+
resourceType,
|
|
655
|
+
resourceId: id,
|
|
656
|
+
outcome: 'success',
|
|
657
|
+
duration: Date.now() - startTime
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
return JSON.parse(response) as T;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
await this.logAudit({
|
|
663
|
+
action: 'read',
|
|
664
|
+
resourceType,
|
|
665
|
+
resourceId: id,
|
|
666
|
+
outcome: 'failure',
|
|
667
|
+
duration: Date.now() - startTime,
|
|
668
|
+
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
|
669
|
+
});
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Search for resources
|
|
676
|
+
*/
|
|
677
|
+
async search(resourceType: string, params: Record<string, string>): Promise<FHIRBundle> {
|
|
678
|
+
const searchParams = new URLSearchParams(params);
|
|
679
|
+
const url = `${this.config.baseUrl}/${resourceType}?${searchParams.toString()}`;
|
|
680
|
+
const startTime = Date.now();
|
|
681
|
+
|
|
682
|
+
try {
|
|
683
|
+
const response = await this.httpRequest(url, {
|
|
684
|
+
method: 'GET',
|
|
685
|
+
headers: await this.getAuthHeaders()
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
await this.logAudit({
|
|
689
|
+
action: 'search',
|
|
690
|
+
resourceType,
|
|
691
|
+
outcome: 'success',
|
|
692
|
+
duration: Date.now() - startTime,
|
|
693
|
+
details: { searchParams: params }
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
return JSON.parse(response) as FHIRBundle;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
await this.logAudit({
|
|
699
|
+
action: 'search',
|
|
700
|
+
resourceType,
|
|
701
|
+
outcome: 'failure',
|
|
702
|
+
duration: Date.now() - startTime,
|
|
703
|
+
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
|
704
|
+
});
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Create a resource
|
|
711
|
+
*/
|
|
712
|
+
async create<T extends FHIRResource>(resource: T): Promise<T> {
|
|
713
|
+
const url = `${this.config.baseUrl}/${resource.resourceType}`;
|
|
714
|
+
const startTime = Date.now();
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
const response = await this.httpRequest(url, {
|
|
718
|
+
method: 'POST',
|
|
719
|
+
headers: {
|
|
720
|
+
...await this.getAuthHeaders(),
|
|
721
|
+
'Content-Type': 'application/fhir+json'
|
|
722
|
+
},
|
|
723
|
+
body: JSON.stringify(resource)
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
await this.logAudit({
|
|
727
|
+
action: 'create',
|
|
728
|
+
resourceType: resource.resourceType,
|
|
729
|
+
outcome: 'success',
|
|
730
|
+
duration: Date.now() - startTime
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
return JSON.parse(response) as T;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
await this.logAudit({
|
|
736
|
+
action: 'create',
|
|
737
|
+
resourceType: resource.resourceType,
|
|
738
|
+
outcome: 'failure',
|
|
739
|
+
duration: Date.now() - startTime,
|
|
740
|
+
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
|
741
|
+
});
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Update a resource
|
|
748
|
+
*/
|
|
749
|
+
async update<T extends FHIRResource>(resource: T & { id: string }): Promise<T> {
|
|
750
|
+
const url = `${this.config.baseUrl}/${resource.resourceType}/${resource.id}`;
|
|
751
|
+
const startTime = Date.now();
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
const response = await this.httpRequest(url, {
|
|
755
|
+
method: 'PUT',
|
|
756
|
+
headers: {
|
|
757
|
+
...await this.getAuthHeaders(),
|
|
758
|
+
'Content-Type': 'application/fhir+json'
|
|
759
|
+
},
|
|
760
|
+
body: JSON.stringify(resource)
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
await this.logAudit({
|
|
764
|
+
action: 'update',
|
|
765
|
+
resourceType: resource.resourceType,
|
|
766
|
+
resourceId: resource.id,
|
|
767
|
+
outcome: 'success',
|
|
768
|
+
duration: Date.now() - startTime
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
return JSON.parse(response) as T;
|
|
772
|
+
} catch (error) {
|
|
773
|
+
await this.logAudit({
|
|
774
|
+
action: 'update',
|
|
775
|
+
resourceType: resource.resourceType,
|
|
776
|
+
resourceId: resource.id,
|
|
777
|
+
outcome: 'failure',
|
|
778
|
+
duration: Date.now() - startTime,
|
|
779
|
+
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
|
780
|
+
});
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
786
|
+
// HELPER METHODS
|
|
787
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
788
|
+
|
|
789
|
+
private async getAuthHeaders(): Promise<Record<string, string>> {
|
|
790
|
+
// Check if token needs refresh
|
|
791
|
+
if (this.tokenExpiry && new Date() >= this.tokenExpiry) {
|
|
792
|
+
await this.authenticate();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const headers: Record<string, string> = {
|
|
796
|
+
'Accept': 'application/fhir+json'
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
if (this.accessToken) {
|
|
800
|
+
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
801
|
+
} else if (this.config.authType === 'basic') {
|
|
802
|
+
headers['Authorization'] = `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64')}`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return headers;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private async httpRequest(url: string, options: {
|
|
809
|
+
method: string;
|
|
810
|
+
headers?: Record<string, string>;
|
|
811
|
+
body?: string;
|
|
812
|
+
}): Promise<string> {
|
|
813
|
+
// Use native fetch in Node.js 18+
|
|
814
|
+
const response = await fetch(url, {
|
|
815
|
+
method: options.method,
|
|
816
|
+
headers: options.headers,
|
|
817
|
+
body: options.body,
|
|
818
|
+
signal: AbortSignal.timeout(this.config.timeout || 30000)
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
if (!response.ok) {
|
|
822
|
+
const errorBody = await response.text();
|
|
823
|
+
throw new Error(`FHIR request failed: ${response.status} ${response.statusText} - ${errorBody}`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return await response.text();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private mapConditionToCancerDiagnosis(condition: FHIRCondition, patientId: string): CancerDiagnosis {
|
|
830
|
+
const icdCoding = condition.code.coding?.find(c =>
|
|
831
|
+
c.system?.includes('icd-10') || c.system?.includes('icd-9')
|
|
832
|
+
);
|
|
833
|
+
const snomedCoding = condition.code.coding?.find(c =>
|
|
834
|
+
c.system?.includes('snomed')
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const stageInfo = condition.stage?.[0];
|
|
838
|
+
let stage: CancerDiagnosis['stage'];
|
|
839
|
+
|
|
840
|
+
if (stageInfo?.summary) {
|
|
841
|
+
const stageCode = stageInfo.summary.coding?.[0]?.code || stageInfo.summary.text || '';
|
|
842
|
+
stage = {
|
|
843
|
+
system: this.determineStageSystem(stageCode),
|
|
844
|
+
stage: stageCode
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
patientId,
|
|
850
|
+
conditionId: condition.id,
|
|
851
|
+
cancerType: condition.code.text || condition.code.coding?.[0]?.display || 'Unknown',
|
|
852
|
+
icdCode: icdCoding?.code || '',
|
|
853
|
+
snomedCode: snomedCoding?.code,
|
|
854
|
+
primarySite: condition.bodySite?.[0]?.text || condition.bodySite?.[0]?.coding?.[0]?.display,
|
|
855
|
+
stage,
|
|
856
|
+
diagnosisDate: new Date(condition.onsetDateTime || condition.recordedDate || Date.now()),
|
|
857
|
+
verificationStatus: this.mapVerificationStatus(condition.verificationStatus)
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private determineStageSystem(stageCode: string): 'ajcc' | 'figo' | 'rai' | 'binet' | 'iss' | 'other' {
|
|
862
|
+
const code = stageCode.toUpperCase();
|
|
863
|
+
if (code.includes('AJCC') || /^[IV]+[ABC]?$/.test(code) || code.includes('TNM')) {
|
|
864
|
+
return 'ajcc';
|
|
865
|
+
}
|
|
866
|
+
if (code.includes('FIGO')) return 'figo';
|
|
867
|
+
if (code.includes('RAI')) return 'rai';
|
|
868
|
+
if (code.includes('BINET')) return 'binet';
|
|
869
|
+
if (code.includes('ISS')) return 'iss';
|
|
870
|
+
return 'other';
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private mapVerificationStatus(status: FHIRCodeableConcept): CancerDiagnosis['verificationStatus'] {
|
|
874
|
+
const code = status.coding?.[0]?.code?.toLowerCase() || '';
|
|
875
|
+
if (code.includes('confirmed')) return 'confirmed';
|
|
876
|
+
if (code.includes('provisional')) return 'provisional';
|
|
877
|
+
if (code.includes('differential')) return 'differential';
|
|
878
|
+
if (code.includes('refuted')) return 'refuted';
|
|
879
|
+
return 'confirmed';
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private aggregateBiomarkerData(
|
|
883
|
+
patientId: string,
|
|
884
|
+
labBundle: FHIRBundle,
|
|
885
|
+
genomicBundle: FHIRBundle,
|
|
886
|
+
reportBundle: FHIRBundle
|
|
887
|
+
): CancerBiomarkers | null {
|
|
888
|
+
const biomarkers: CancerBiomarkers['biomarkers'] = [];
|
|
889
|
+
const genomicAlterations: CancerBiomarkers['genomicAlterations'] = [];
|
|
890
|
+
let latestDate = new Date(0);
|
|
891
|
+
|
|
892
|
+
// Process lab observations
|
|
893
|
+
for (const entry of labBundle.entry || []) {
|
|
894
|
+
const obs = entry.resource as FHIRObservation;
|
|
895
|
+
if (obs.resourceType !== 'Observation') continue;
|
|
896
|
+
|
|
897
|
+
const date = new Date(obs.effectiveDateTime || Date.now());
|
|
898
|
+
if (date > latestDate) latestDate = date;
|
|
899
|
+
|
|
900
|
+
// Map common cancer biomarkers
|
|
901
|
+
const biomarker = this.mapObservationToBiomarker(obs);
|
|
902
|
+
if (biomarker) biomarkers.push(biomarker);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Process genomic observations
|
|
906
|
+
for (const entry of genomicBundle.entry || []) {
|
|
907
|
+
const obs = entry.resource as FHIRObservation;
|
|
908
|
+
if (obs.resourceType !== 'Observation') continue;
|
|
909
|
+
|
|
910
|
+
const date = new Date(obs.effectiveDateTime || Date.now());
|
|
911
|
+
if (date > latestDate) latestDate = date;
|
|
912
|
+
|
|
913
|
+
const alteration = this.mapObservationToGenomicAlteration(obs);
|
|
914
|
+
if (alteration) genomicAlterations.push(alteration);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Process diagnostic reports for additional genomic data
|
|
918
|
+
for (const entry of reportBundle.entry || []) {
|
|
919
|
+
const report = entry.resource as FHIRDiagnosticReport;
|
|
920
|
+
if (report.resourceType !== 'DiagnosticReport') continue;
|
|
921
|
+
|
|
922
|
+
const date = new Date(report.effectiveDateTime || Date.now());
|
|
923
|
+
if (date > latestDate) latestDate = date;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (biomarkers.length === 0 && genomicAlterations.length === 0) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Extract special biomarkers
|
|
931
|
+
const tmbObs = this.findBiomarker(biomarkers, ['TMB', 'tumor mutational burden']);
|
|
932
|
+
const msiObs = this.findBiomarker(biomarkers, ['MSI', 'microsatellite']);
|
|
933
|
+
const pdl1Obs = this.findBiomarker(biomarkers, ['PD-L1', 'PDL1']);
|
|
934
|
+
const hrdObs = this.findBiomarker(biomarkers, ['HRD', 'homologous recombination']);
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
patientId,
|
|
938
|
+
collectionDate: latestDate,
|
|
939
|
+
biomarkers,
|
|
940
|
+
genomicAlterations: genomicAlterations.length > 0 ? genomicAlterations : undefined,
|
|
941
|
+
tumorMutationalBurden: tmbObs ? {
|
|
942
|
+
value: typeof tmbObs.value === 'number' ? tmbObs.value : parseFloat(String(tmbObs.value)) || 0,
|
|
943
|
+
unit: 'mutations/Mb',
|
|
944
|
+
status: this.categorizeTMB(tmbObs.value)
|
|
945
|
+
} : undefined,
|
|
946
|
+
microsatelliteInstability: msiObs ? {
|
|
947
|
+
status: this.categorizeMSI(msiObs.value),
|
|
948
|
+
method: 'NGS'
|
|
949
|
+
} : undefined,
|
|
950
|
+
pdl1Expression: pdl1Obs ? {
|
|
951
|
+
score: typeof pdl1Obs.value === 'number' ? pdl1Obs.value : parseFloat(String(pdl1Obs.value)) || 0,
|
|
952
|
+
scoreType: 'TPS'
|
|
953
|
+
} : undefined,
|
|
954
|
+
hrdStatus: hrdObs ? {
|
|
955
|
+
status: hrdObs.status === 'positive' ? 'positive' : 'negative',
|
|
956
|
+
score: typeof hrdObs.value === 'number' ? hrdObs.value : undefined
|
|
957
|
+
} : undefined
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private mapObservationToBiomarker(obs: FHIRObservation): CancerBiomarkers['biomarkers'][0] | null {
|
|
962
|
+
const name = obs.code.text || obs.code.coding?.[0]?.display;
|
|
963
|
+
if (!name) return null;
|
|
964
|
+
|
|
965
|
+
let value: string | number;
|
|
966
|
+
let status: 'positive' | 'negative' | 'equivocal' | 'not-tested';
|
|
967
|
+
|
|
968
|
+
if (obs.valueQuantity?.value !== undefined) {
|
|
969
|
+
value = obs.valueQuantity.value;
|
|
970
|
+
status = 'positive'; // Will be refined based on interpretation
|
|
971
|
+
} else if (obs.valueCodeableConcept) {
|
|
972
|
+
value = obs.valueCodeableConcept.text || obs.valueCodeableConcept.coding?.[0]?.display || '';
|
|
973
|
+
status = this.interpretBiomarkerStatus(value);
|
|
974
|
+
} else if (obs.valueString) {
|
|
975
|
+
value = obs.valueString;
|
|
976
|
+
status = this.interpretBiomarkerStatus(value);
|
|
977
|
+
} else {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Check interpretation if available
|
|
982
|
+
if (obs.interpretation?.[0]?.coding?.[0]?.code) {
|
|
983
|
+
const interpCode = obs.interpretation[0].coding[0].code;
|
|
984
|
+
if (interpCode === 'POS' || interpCode === 'H') status = 'positive';
|
|
985
|
+
else if (interpCode === 'NEG' || interpCode === 'N') status = 'negative';
|
|
986
|
+
else if (interpCode === 'IND') status = 'equivocal';
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
name,
|
|
991
|
+
value,
|
|
992
|
+
unit: obs.valueQuantity?.unit,
|
|
993
|
+
status,
|
|
994
|
+
loincCode: obs.code.coding?.find(c => c.system?.includes('loinc'))?.code
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private mapObservationToGenomicAlteration(obs: FHIRObservation): CancerBiomarkers['genomicAlterations'][0] | null {
|
|
999
|
+
// This would need to be expanded to handle the full mCode/genomics-reporting IG
|
|
1000
|
+
const geneComponent = obs.component?.find(c =>
|
|
1001
|
+
c.code.coding?.some(coding => coding.code === '48018-6') // Gene studied
|
|
1002
|
+
);
|
|
1003
|
+
const variantComponent = obs.component?.find(c =>
|
|
1004
|
+
c.code.coding?.some(coding => coding.code === '81252-9') // DNA change
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
if (!geneComponent || !variantComponent) return null;
|
|
1008
|
+
|
|
1009
|
+
const gene = geneComponent.valueCodeableConcept?.text ||
|
|
1010
|
+
geneComponent.valueCodeableConcept?.coding?.[0]?.display || '';
|
|
1011
|
+
const alteration = variantComponent.valueString ||
|
|
1012
|
+
variantComponent.valueCodeableConcept?.text || '';
|
|
1013
|
+
|
|
1014
|
+
if (!gene || !alteration) return null;
|
|
1015
|
+
|
|
1016
|
+
return {
|
|
1017
|
+
gene,
|
|
1018
|
+
alteration,
|
|
1019
|
+
type: this.determineAlterationType(alteration)
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private determineAlterationType(alteration: string): 'mutation' | 'amplification' | 'deletion' | 'fusion' | 'rearrangement' {
|
|
1024
|
+
const lower = alteration.toLowerCase();
|
|
1025
|
+
if (lower.includes('amp') || lower.includes('gain')) return 'amplification';
|
|
1026
|
+
if (lower.includes('del') || lower.includes('loss')) return 'deletion';
|
|
1027
|
+
if (lower.includes('fusion') || lower.includes('::')) return 'fusion';
|
|
1028
|
+
if (lower.includes('rearr') || lower.includes('transloc')) return 'rearrangement';
|
|
1029
|
+
return 'mutation';
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private interpretBiomarkerStatus(value: string | number): 'positive' | 'negative' | 'equivocal' | 'not-tested' {
|
|
1033
|
+
const lower = String(value).toLowerCase();
|
|
1034
|
+
if (lower.includes('positive') || lower.includes('detected') || lower === 'yes') return 'positive';
|
|
1035
|
+
if (lower.includes('negative') || lower.includes('not detected') || lower === 'no') return 'negative';
|
|
1036
|
+
if (lower.includes('equivocal') || lower.includes('indeterminate') || lower.includes('borderline')) return 'equivocal';
|
|
1037
|
+
return 'positive'; // Default to positive if we have a value
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private findBiomarker(biomarkers: CancerBiomarkers['biomarkers'], keywords: string[]): CancerBiomarkers['biomarkers'][0] | undefined {
|
|
1041
|
+
return biomarkers.find(b =>
|
|
1042
|
+
keywords.some(k => b.name.toLowerCase().includes(k.toLowerCase()))
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private categorizeTMB(value: string | number): 'high' | 'intermediate' | 'low' {
|
|
1047
|
+
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
|
1048
|
+
if (isNaN(numValue)) return 'low';
|
|
1049
|
+
if (numValue >= 10) return 'high';
|
|
1050
|
+
if (numValue >= 6) return 'intermediate';
|
|
1051
|
+
return 'low';
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
private categorizeMSI(value: string | number): 'MSI-H' | 'MSI-L' | 'MSS' {
|
|
1055
|
+
const strValue = String(value).toUpperCase();
|
|
1056
|
+
if (strValue.includes('MSI-H') || strValue.includes('HIGH') || strValue.includes('UNSTABLE')) return 'MSI-H';
|
|
1057
|
+
if (strValue.includes('MSI-L') || strValue.includes('LOW')) return 'MSI-L';
|
|
1058
|
+
return 'MSS';
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private aggregateTreatmentHistory(
|
|
1062
|
+
patientId: string,
|
|
1063
|
+
medications: FHIRBundle,
|
|
1064
|
+
procedures: FHIRBundle
|
|
1065
|
+
): TreatmentHistory {
|
|
1066
|
+
const treatments: TreatmentHistory['treatments'] = [];
|
|
1067
|
+
|
|
1068
|
+
// Process medications
|
|
1069
|
+
for (const entry of medications.entry || []) {
|
|
1070
|
+
const med = entry.resource as FHIRMedicationRequest;
|
|
1071
|
+
if (med.resourceType !== 'MedicationRequest') continue;
|
|
1072
|
+
|
|
1073
|
+
const drugName = med.medicationCodeableConcept?.text ||
|
|
1074
|
+
med.medicationCodeableConcept?.coding?.[0]?.display ||
|
|
1075
|
+
'Unknown medication';
|
|
1076
|
+
|
|
1077
|
+
const treatment = this.categorizeTreatment(drugName);
|
|
1078
|
+
|
|
1079
|
+
treatments.push({
|
|
1080
|
+
id: med.id,
|
|
1081
|
+
type: treatment.type,
|
|
1082
|
+
regimen: treatment.regimen,
|
|
1083
|
+
drugs: [{
|
|
1084
|
+
name: drugName,
|
|
1085
|
+
dose: med.dosageInstruction?.[0]?.text,
|
|
1086
|
+
route: med.dosageInstruction?.[0]?.route?.text,
|
|
1087
|
+
rxNormCode: med.medicationCodeableConcept?.coding?.find(c =>
|
|
1088
|
+
c.system?.includes('rxnorm')
|
|
1089
|
+
)?.code
|
|
1090
|
+
}],
|
|
1091
|
+
startDate: new Date(med.authoredOn || Date.now()),
|
|
1092
|
+
status: this.mapMedicationStatus(med.status)
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Process procedures (surgery, radiation)
|
|
1097
|
+
for (const entry of procedures.entry || []) {
|
|
1098
|
+
const proc = entry.resource as FHIRProcedure;
|
|
1099
|
+
if (proc.resourceType !== 'Procedure') continue;
|
|
1100
|
+
|
|
1101
|
+
const procName = proc.code.text || proc.code.coding?.[0]?.display || 'Unknown procedure';
|
|
1102
|
+
const isSurgery = this.isSurgicalProcedure(procName);
|
|
1103
|
+
const isRadiation = this.isRadiationProcedure(procName);
|
|
1104
|
+
|
|
1105
|
+
if (isSurgery || isRadiation) {
|
|
1106
|
+
treatments.push({
|
|
1107
|
+
id: proc.id,
|
|
1108
|
+
type: isRadiation ? 'radiation' : 'surgery',
|
|
1109
|
+
drugs: [],
|
|
1110
|
+
startDate: new Date(proc.performedDateTime || proc.performedPeriod?.start || Date.now()),
|
|
1111
|
+
endDate: proc.performedPeriod?.end ? new Date(proc.performedPeriod.end) : undefined,
|
|
1112
|
+
status: this.mapProcedureStatus(proc.status)
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Sort by date
|
|
1118
|
+
treatments.sort((a, b) => b.startDate.getTime() - a.startDate.getTime());
|
|
1119
|
+
|
|
1120
|
+
return { patientId, treatments };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private categorizeTreatment(drugName: string): { type: TreatmentHistory['treatments'][0]['type']; regimen?: string } {
|
|
1124
|
+
const lower = drugName.toLowerCase();
|
|
1125
|
+
|
|
1126
|
+
// Immunotherapy
|
|
1127
|
+
const immunotherapyDrugs = ['pembrolizumab', 'nivolumab', 'ipilimumab', 'atezolizumab', 'durvalumab',
|
|
1128
|
+
'avelumab', 'cemiplimab', 'dostarlimab', 'relatlimab', 'tremelimumab'];
|
|
1129
|
+
if (immunotherapyDrugs.some(d => lower.includes(d))) {
|
|
1130
|
+
return { type: 'immunotherapy' };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Targeted therapy
|
|
1134
|
+
const targetedDrugs = ['imatinib', 'erlotinib', 'gefitinib', 'osimertinib', 'crizotinib', 'alectinib',
|
|
1135
|
+
'palbociclib', 'ribociclib', 'abemaciclib', 'olaparib', 'rucaparib', 'niraparib', 'trastuzumab',
|
|
1136
|
+
'pertuzumab', 'lapatinib', 'vemurafenib', 'dabrafenib', 'trametinib', 'sotorasib', 'adagrasib',
|
|
1137
|
+
'venetoclax', 'ibrutinib', 'acalabrutinib', 'lenvatinib', 'sorafenib', 'regorafenib', 'cabozantinib',
|
|
1138
|
+
'bevacizumab', 'cetuximab', 'panitumumab', 'rituximab'];
|
|
1139
|
+
if (targetedDrugs.some(d => lower.includes(d))) {
|
|
1140
|
+
return { type: 'targeted-therapy' };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Hormone therapy
|
|
1144
|
+
const hormoneDrugs = ['tamoxifen', 'letrozole', 'anastrozole', 'exemestane', 'fulvestrant',
|
|
1145
|
+
'enzalutamide', 'abiraterone', 'apalutamide', 'darolutamide', 'lupron', 'leuprolide'];
|
|
1146
|
+
if (hormoneDrugs.some(d => lower.includes(d))) {
|
|
1147
|
+
return { type: 'hormone-therapy' };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// CAR-T
|
|
1151
|
+
const carTDrugs = ['tisagenlecleucel', 'axicabtagene', 'brexucabtagene', 'lisocabtagene',
|
|
1152
|
+
'idecabtagene', 'ciltacabtagene', 'kymriah', 'yescarta', 'tecartus', 'breyanzi', 'abecma', 'carvykti'];
|
|
1153
|
+
if (carTDrugs.some(d => lower.includes(d))) {
|
|
1154
|
+
return { type: 'car-t' };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Chemotherapy (catch-all for cytotoxic agents)
|
|
1158
|
+
const chemoDrugs = ['carboplatin', 'cisplatin', 'oxaliplatin', 'paclitaxel', 'docetaxel',
|
|
1159
|
+
'doxorubicin', 'epirubicin', 'cyclophosphamide', 'fluorouracil', '5-fu', 'capecitabine',
|
|
1160
|
+
'gemcitabine', 'pemetrexed', 'etoposide', 'irinotecan', 'vincristine', 'vinblastine',
|
|
1161
|
+
'methotrexate', 'cytarabine', 'azacitidine', 'decitabine', 'temozolomide'];
|
|
1162
|
+
if (chemoDrugs.some(d => lower.includes(d))) {
|
|
1163
|
+
return { type: 'chemotherapy' };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return { type: 'other' };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private mapMedicationStatus(status: FHIRMedicationRequest['status']): TreatmentHistory['treatments'][0]['status'] {
|
|
1170
|
+
switch (status) {
|
|
1171
|
+
case 'active': return 'active';
|
|
1172
|
+
case 'completed': return 'completed';
|
|
1173
|
+
case 'stopped': return 'stopped';
|
|
1174
|
+
case 'on-hold': return 'on-hold';
|
|
1175
|
+
case 'draft': return 'planned';
|
|
1176
|
+
default: return 'active';
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private mapProcedureStatus(status: FHIRProcedure['status']): TreatmentHistory['treatments'][0]['status'] {
|
|
1181
|
+
switch (status) {
|
|
1182
|
+
case 'completed': return 'completed';
|
|
1183
|
+
case 'in-progress': return 'active';
|
|
1184
|
+
case 'preparation': return 'planned';
|
|
1185
|
+
case 'on-hold': return 'on-hold';
|
|
1186
|
+
case 'stopped': return 'stopped';
|
|
1187
|
+
default: return 'completed';
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private isSurgicalProcedure(name: string): boolean {
|
|
1192
|
+
const surgeryKeywords = ['surgery', 'resection', 'excision', 'mastectomy', 'lobectomy',
|
|
1193
|
+
'colectomy', 'gastrectomy', 'prostatectomy', 'nephrectomy', 'hysterectomy',
|
|
1194
|
+
'lymphadenectomy', 'biopsy', 'debulking', 'whipple', 'hepatectomy'];
|
|
1195
|
+
return surgeryKeywords.some(k => name.toLowerCase().includes(k));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private isRadiationProcedure(name: string): boolean {
|
|
1199
|
+
const radiationKeywords = ['radiation', 'radiotherapy', 'sbrt', 'imrt', 'proton',
|
|
1200
|
+
'brachytherapy', 'cyberknife', 'gamma knife', 'external beam'];
|
|
1201
|
+
return radiationKeywords.some(k => name.toLowerCase().includes(k));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
private async logAudit(event: Partial<AuditEvent>): Promise<void> {
|
|
1205
|
+
if (!this.auditLogger) return;
|
|
1206
|
+
|
|
1207
|
+
const fullEvent: AuditEvent = {
|
|
1208
|
+
timestamp: new Date(),
|
|
1209
|
+
userId: 'system',
|
|
1210
|
+
ipAddress: '0.0.0.0',
|
|
1211
|
+
action: event.action || 'unknown',
|
|
1212
|
+
resourceType: event.resourceType || 'unknown',
|
|
1213
|
+
outcome: event.outcome || 'unknown',
|
|
1214
|
+
...event
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
await this.auditLogger(fullEvent);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
console.error('Failed to log audit event:', error);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1226
|
+
// AUDIT EVENT TYPE
|
|
1227
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1228
|
+
|
|
1229
|
+
export interface AuditEvent {
|
|
1230
|
+
timestamp: Date;
|
|
1231
|
+
userId: string;
|
|
1232
|
+
ipAddress: string;
|
|
1233
|
+
action: string;
|
|
1234
|
+
resourceType: string;
|
|
1235
|
+
resourceId?: string;
|
|
1236
|
+
outcome: 'success' | 'failure' | 'unknown';
|
|
1237
|
+
duration?: number;
|
|
1238
|
+
errorMessage?: string;
|
|
1239
|
+
details?: Record<string, any>;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1243
|
+
// EHR VENDOR-SPECIFIC ADAPTERS
|
|
1244
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1245
|
+
|
|
1246
|
+
export class EpicFHIRClient extends FHIRClient {
|
|
1247
|
+
constructor(config: Omit<FHIRConfig, 'ehrVendor'>) {
|
|
1248
|
+
super({ ...config, ehrVendor: 'epic' });
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Epic-specific: Get MyChart patient context
|
|
1253
|
+
*/
|
|
1254
|
+
async getMyChartContext(launchToken: string): Promise<{
|
|
1255
|
+
patient: string;
|
|
1256
|
+
encounter?: string;
|
|
1257
|
+
practitioner?: string;
|
|
1258
|
+
}> {
|
|
1259
|
+
// Epic SMART launch context parsing
|
|
1260
|
+
const decoded = Buffer.from(launchToken, 'base64').toString();
|
|
1261
|
+
return JSON.parse(decoded);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export class CernerFHIRClient extends FHIRClient {
|
|
1266
|
+
constructor(config: Omit<FHIRConfig, 'ehrVendor'>) {
|
|
1267
|
+
super({ ...config, ehrVendor: 'cerner' });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Cerner-specific: Handle Millennium-specific extensions
|
|
1272
|
+
*/
|
|
1273
|
+
parseMillenniumExtensions(resource: FHIRResource): Record<string, any> {
|
|
1274
|
+
const extensions: Record<string, any> = {};
|
|
1275
|
+
|
|
1276
|
+
if ('extension' in resource && resource.extension) {
|
|
1277
|
+
for (const ext of resource.extension) {
|
|
1278
|
+
if (ext.url.includes('cerner.com')) {
|
|
1279
|
+
const key = ext.url.split('/').pop() || ext.url;
|
|
1280
|
+
extensions[key] = ext.valueString || ext.valueCode || ext.valueBoolean || ext.valueCoding;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return extensions;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1290
|
+
// FACTORY FUNCTION
|
|
1291
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1292
|
+
|
|
1293
|
+
export function createFHIRClient(config: FHIRConfig): FHIRClient {
|
|
1294
|
+
switch (config.ehrVendor) {
|
|
1295
|
+
case 'epic':
|
|
1296
|
+
return new EpicFHIRClient(config);
|
|
1297
|
+
case 'cerner':
|
|
1298
|
+
return new CernerFHIRClient(config);
|
|
1299
|
+
default:
|
|
1300
|
+
return new FHIRClient(config);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
export default FHIRClient;
|