Class: MedicalRecords::Client

Inherits:
Common::Client::Base show all
Includes:
Common::Client::Concerns::MhvFhirSessionClient
Defined in:
lib/medical_records/client.rb

Overview

Core class responsible for Medical Records API interface operations

Constant Summary collapse

DEFAULT_COUNT =

Default number of records to request per call when searching

9999
PHYSICIAN_PROCEDURE_NOTE =

LOINC codes for clinical notes

'11506-3'
DISCHARGE_SUMMARY =

Physician procedure note

'18842-5'
BLOOD_PRESSURE =

LOINC codes for vitals

'85354-9'
BREATHING_RATE =

Blood Pressure

'9279-1'
HEART_RATE =

Breathing Rate

'8867-4'
HEIGHT =

Heart Rate

'8302-2'
TEMPERATURE =

Height

'8310-5'
WEIGHT =

Temperature

'29463-7'
MICROBIOLOGY =

LOINC codes for labs & tests

'79381-0'
PATHOLOGY =

Gastrointestinal pathogens panel

'60567-5'
EKG =

Comprehensive pathology report panel

'11524-6'
RADIOLOGY =

EKG Study

'18748-4'

Constants included from Common::Client::Concerns::MhvFhirSessionClient

Common::Client::Concerns::MhvFhirSessionClient::LOCK_RETRY_DELAY, Common::Client::Concerns::MhvFhirSessionClient::RETRY_ATTEMPTS

Constants included from Common::Client::Concerns::MhvLockedSessionClient

Common::Client::Concerns::MhvLockedSessionClient::LOCK_RETRY_DELAY, Common::Client::Concerns::MhvLockedSessionClient::RETRY_ATTEMPTS

Instance Attribute Summary

Attributes included from Common::Client::Concerns::MhvFhirSessionClient

#session

Attributes included from Common::Client::Concerns::MHVJwtSessionClient

#session

Attributes included from Common::Client::Concerns::MhvLockedSessionClient

#session

Instance Method Summary collapse

Methods included from Common::Client::Concerns::MhvFhirSessionClient

#get_session, #incomplete?, #invalid?

Methods included from Common::Client::Concerns::MHVJwtSessionClient

#get_session, #session_config_key, #user_key

Methods included from Common::Client::Concerns::MhvLockedSessionClient

#authenticate, #initialize, #invalid?, #refresh_session

Methods included from SentryLogging

#log_exception_to_sentry, #log_message_to_sentry, #non_nil_hash?, #normalize_level, #rails_logger

Methods inherited from Common::Client::Base

configuration, #raise_backend_exception

Instance Method Details

#base_pathString

Returns Base path for dependent URLs.

Returns:

  • (String)

    Base path for dependent URLs



43
44
45
# File 'lib/medical_records/client.rb', line 43

def base_path
  "#{Settings.mhv.medical_records.host}/fhir/"
end

#fetch_nested_value(object, field_path) ⇒ Object (protected)

Fetches the value of a potentially nested field from a given object.

Parameters:

  • object (Object)

    the object to fetch the value from

  • field_path (String)

    the dot-separated path to the field



386
387
388
389
390
# File 'lib/medical_records/client.rb', line 386

def fetch_nested_value(object, field_path)
  field_path.split('.').reduce(object) do |obj, method|
    obj.respond_to?(method) ? obj.send(method) : nil
  end
end

#fhir_clientFHIR::Client

Create a new FHIR::Client instance based on the client_session. Use an existing client if one already exists in this instance.

Returns:

  • (FHIR::Client)


72
73
74
# File 'lib/medical_records/client.rb', line 72

def fhir_client
  @fhir_client ||= sessionless_fhir_client(jwt_bearer_token)
end

#fhir_read(fhir_model, id) ⇒ Object (protected)



271
272
273
274
275
# File 'lib/medical_records/client.rb', line 271

def fhir_read(fhir_model, id)
  result = fhir_client.read(fhir_model, id)
  handle_api_errors(result) if result.resource.nil?
  result.resource
end

#fhir_search(fhir_model, params) ⇒ FHIR::Bundle (protected)

Perform a FHIR search. This method will continue making queries until all results have been returned.

Parameters:

  • fhir_model (FHIR::Model)

    The type of resource to search

  • params (Hash)

    The parameters to pass the search

Returns:

  • (FHIR::Bundle)


244
245
246
247
248
249
250
251
252
253
254
# File 'lib/medical_records/client.rb', line 244

def fhir_search(fhir_model, params)
  reply = fhir_search_query(fhir_model, params)
  combined_bundle = reply.resource
  loop do
    break unless reply.resource.next_link

    reply = fhir_client.next_page(reply)
    combined_bundle = merge_bundles(combined_bundle, reply.resource)
  end
  combined_bundle
end

#fhir_search_query(fhir_model, params) ⇒ FHIR::ClientReply (protected)

Perform a FHIR search. Returns the first page of results only. Filters out FHIR records that are not active.

Parameters:

  • fhir_model (FHIR::Model)

    The type of resource to search

  • params (Hash)

    The parameters to pass the search

Returns:

  • (FHIR::ClientReply)


264
265
266
267
268
269
# File 'lib/medical_records/client.rb', line 264

def fhir_search_query(fhir_model, params)
  params[:search][:parameters].merge!(_count: DEFAULT_COUNT)
  result = fhir_client.search(fhir_model, params)
  handle_api_errors(result) if result.resource.nil?
  result
end

#get_allergy(allergy_id) ⇒ Object



95
96
97
# File 'lib/medical_records/client.rb', line 95

def get_allergy(allergy_id)
  fhir_read(FHIR::AllergyIntolerance, allergy_id)
end

#get_clinical_note(note_id) ⇒ Object



149
150
151
# File 'lib/medical_records/client.rb', line 149

def get_clinical_note(note_id)
  fhir_read(FHIR::DocumentReference, note_id)
end

#get_condition(condition_id) ⇒ Object



123
124
125
# File 'lib/medical_records/client.rb', line 123

def get_condition(condition_id)
  fhir_search(FHIR::Condition, search: { parameters: { _id: condition_id, _include: '*' } })
end

#get_diagnostic_report(record_id) ⇒ Object



153
154
155
# File 'lib/medical_records/client.rb', line 153

def get_diagnostic_report(record_id)
  fhir_search(FHIR::DiagnosticReport, search: { parameters: { _id: record_id, _include: '*' } })
end

#get_patient_by_identifier(fhir_client, identifier) ⇒ Object



76
77
78
79
80
81
82
83
84
# File 'lib/medical_records/client.rb', line 76

def get_patient_by_identifier(fhir_client, identifier)
  result = fhir_client.search(FHIR::Patient, {
                                search: { parameters: { identifier: } },
                                headers: { 'Cache-Control': 'no-cache' }
                              })
  resource = result.resource
  handle_api_errors(result) if resource.nil?
  resource
end

#get_vaccine(vaccine_id) ⇒ Object



108
109
110
# File 'lib/medical_records/client.rb', line 108

def get_vaccine(vaccine_id)
  fhir_read(FHIR::Immunization, vaccine_id)
end

#handle_api_errors(result) ⇒ Object (protected)



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/medical_records/client.rb', line 277

def handle_api_errors(result)
  if result.code.present? && result.code >= 400
    body = JSON.parse(result.body)
    diagnostics = body['issue']&.first&.fetch('diagnostics', nil)
    diagnostics = "Error fetching data#{": #{diagnostics}" if diagnostics}"

    # Special-case exception handling
    if result.code == 500 && diagnostics.include?('HAPI-1363')
      # "HAPI-1363: Either No patient or multiple patient found"
      raise MedicalRecords::PatientNotFound
    end

    # Default exception handling
    raise Common::Exceptions::BackendServiceException.new(
      "MEDICALRECORDS_#{result.code}",
      status: result.code,
      detail: diagnostics,
      source: self.class.to_s
    )
  end
end

#list_allergiesObject



86
87
88
89
90
91
92
93
# File 'lib/medical_records/client.rb', line 86

def list_allergies
  bundle = fhir_search(FHIR::AllergyIntolerance,
                       {
                         search: { parameters: { patient: patient_fhir_id, 'clinical-status': 'active' } },
                         headers: { 'Cache-Control': 'no-cache' }
                       })
  sort_bundle(bundle, :recordedDate, :desc)
end

#list_clinical_notesObject



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/medical_records/client.rb', line 127

def list_clinical_notes
  loinc_codes = "#{PHYSICIAN_PROCEDURE_NOTE},#{DISCHARGE_SUMMARY}"
  bundle = fhir_search(FHIR::DocumentReference,
                       search: { parameters: { patient: patient_fhir_id, type: loinc_codes } })

  # Sort the bundle of notes based on the date field appropriate to each note type.
  sort_bundle_with_criteria(bundle, :desc) do |resource|
    loinc_code = if resource.respond_to?(:type) && resource.type.respond_to?(:coding)
                   resource.type.coding&.find do |coding|
                     coding.respond_to?(:system) && coding.system == 'http://loinc.org'
                   end&.code
                 end

    case loinc_code
    when PHYSICIAN_PROCEDURE_NOTE
      resource.date
    when DISCHARGE_SUMMARY
      resource.context&.period&.end
    end
  end
end

#list_conditionsObject



118
119
120
121
# File 'lib/medical_records/client.rb', line 118

def list_conditions
  bundle = fhir_search(FHIR::Condition, search: { parameters: { patient: patient_fhir_id } })
  sort_bundle(bundle, :recordedDate, :desc)
end

#list_labs_and_tests(page_size = 999, page_num = 1) ⇒ FHIR::Bundle

Fetch Lab & Tests results for the given patient. This combines the results of three separate calls.

Parameters:

  • patient_id (Fixnum)

    MHV patient ID

Returns:

  • (FHIR::Bundle)


163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/medical_records/client.rb', line 163

def list_labs_and_tests(page_size = 999, page_num = 1)
  combined_bundle = FHIR::Bundle.new
  combined_bundle.type = 'searchset'

  # Make the individual API calls.
  labs_diagrep_chemhem = list_labs_chemhem_diagnostic_report
  labs_diagrep_other = list_labs_other_diagnostic_report
  labs_docref = list_labs_document_reference

  # TODO: Figure out how to do this in threads.
  # labs_diagrep_chemhem_thread = Thread.new { list_labs_chemhem_diagnostic_report(patient_id) }
  # labs_diagrep_other_thread = Thread.new { list_labs_other_diagnostic_report(patient_id) }
  # labs_docref_thread = Thread.new { list_labs_document_reference(patient_id) }
  # labs_diagrep_chemhem_thread.join
  # labs_diagrep_other_thread.join
  # labs_docref_thread.join
  # labs_diagrep_chemhem = labs_diagrep_chemhem_thread.value
  # labs_diagrep_other = labs_diagrep_other_thread.value
  # labs_docref = labs_docref_thread.value

  # Merge the entry arrays into the combined bundle.
  combined_bundle.entry.concat(labs_diagrep_chemhem.entry) if labs_diagrep_chemhem.entry
  combined_bundle.entry.concat(labs_diagrep_other.entry) if labs_diagrep_other.entry
  combined_bundle.entry.concat(labs_docref.entry) if labs_docref.entry

  # Ensure an accurate total count for the combined bundle.
  combined_bundle.total = (labs_diagrep_chemhem&.total || 0) + (labs_diagrep_other&.total || 0) +
                          (labs_docref&.total || 0)

  # Sort the combined_bundle.entry array by date in reverse chronological order
  sort_lab_entries(combined_bundle.entry)

  # Apply pagination
  combined_bundle.entry = paginate_bundle_entries(combined_bundle.entry, page_size, page_num)

  combined_bundle
end

#list_labs_chemhem_diagnostic_reportFHIR::Bundle (protected)

Fetch Chemistry/Hematology results for the given patient

Parameters:

  • patient_id (Fixnum)

    MHV patient ID

Returns:

  • (FHIR::Bundle)


209
210
211
212
# File 'lib/medical_records/client.rb', line 209

def list_labs_chemhem_diagnostic_report
  fhir_search(FHIR::DiagnosticReport,
              search: { parameters: { patient: patient_fhir_id, category: 'LAB' } })
end

#list_labs_document_referenceFHIR::Bundle (protected)

Fetch EKG and Radiology results for the given patient

Parameters:

  • patient_id (Fixnum)

    MHV patient ID

Returns:

  • (FHIR::Bundle)


231
232
233
234
235
# File 'lib/medical_records/client.rb', line 231

def list_labs_document_reference
  loinc_codes = "#{EKG},#{RADIOLOGY}"
  fhir_search(FHIR::DocumentReference,
              search: { parameters: { patient: patient_fhir_id, type: loinc_codes } })
end

#list_labs_other_diagnostic_reportFHIR::Bundle (protected)

Fetch Microbiology and Pathology results for the given patient

Parameters:

  • patient_id (Fixnum)

    MHV patient ID

Returns:

  • (FHIR::Bundle)


220
221
222
223
# File 'lib/medical_records/client.rb', line 220

def list_labs_other_diagnostic_report
  loinc_codes = "#{MICROBIOLOGY},#{PATHOLOGY}"
  fhir_search(FHIR::DiagnosticReport, search: { parameters: { patient: patient_fhir_id, code: loinc_codes } })
end

#list_vaccinesObject



99
100
101
102
103
104
105
106
# File 'lib/medical_records/client.rb', line 99

def list_vaccines
  bundle = fhir_search(FHIR::Immunization,
                       {
                         search: { parameters: { patient: patient_fhir_id } },
                         headers: { 'Cache-Control': 'no-cache' }
                       })
  sort_bundle(bundle, :occurrenceDateTime, :desc)
end

#list_vitalsObject



112
113
114
115
116
# File 'lib/medical_records/client.rb', line 112

def list_vitals
  loinc_codes = "#{BLOOD_PRESSURE},#{BREATHING_RATE},#{HEART_RATE},#{HEIGHT},#{TEMPERATURE},#{WEIGHT}"
  bundle = fhir_search(FHIR::Observation, search: { parameters: { patient: patient_fhir_id, code: loinc_codes } })
  sort_bundle(bundle, :effectiveDateTime, :desc)
end

#merge_bundles(bundle1, bundle2) ⇒ Object (protected)

Merge two FHIR bundles into one, with an updated total count.

Parameters:

  • bundle1 (FHIR:Bundle)

    The first FHIR bundle

  • bundle2 (FHIR:Bundle)

    The second FHIR bundle

  • page_num (FHIR:Bundle)


306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/medical_records/client.rb', line 306

def merge_bundles(bundle1, bundle2)
  unless bundle1.resourceType == 'Bundle' && bundle2.resourceType == 'Bundle'
    raise 'Both inputs must be FHIR Bundles'
  end

  # Clone the first bundle to avoid modifying the original
  merged_bundle = bundle1.clone

  # Merge the entries from the second bundle into the merged_bundle
  merged_bundle.entry ||= []
  bundle2.entry&.each do |entry|
    merged_bundle.entry << entry
  end

  # Update the total count in the merged bundle
  merged_bundle.total = merged_bundle.entry.count

  merged_bundle
end

#paginate_bundle_entries(entries, page_size, page_num) ⇒ Object (protected)

Apply pagination to the entries in a FHIR::Bundle object. This assumes sorting has already taken place.

Parameters:

  • entries

    a list of FHIR objects

  • page_size (Fixnum)

    page size

  • page_num (Fixnum)

    which page to return



333
334
335
336
337
338
339
340
# File 'lib/medical_records/client.rb', line 333

def paginate_bundle_entries(entries, page_size, page_num)
  start_index = (page_num - 1) * page_size
  end_index = start_index + page_size
  paginated_entries = entries[start_index...end_index]

  # Return the paginated result or an empty array if no entries
  paginated_entries || []
end

#sessionless_fhir_client(bearer_token) ⇒ FHIR::Client

Create a new FHIR::Client instance, given the provided bearer token. This method does not require a client_session to have been initialized.

Parameters:

  • bearer_token (String)

    The bearer token from the authentication call

Returns:

  • (FHIR::Client)


54
55
56
57
58
59
60
61
62
63
64
# File 'lib/medical_records/client.rb', line 54

def sessionless_fhir_client(bearer_token)
  # FHIR debug level is extremely verbose, printing the full contents of every response body.
  ::FHIR.logger.level = Logger::INFO

  FHIR::Client.new(base_path).tap do |client|
    client.use_r4
    client.default_json
    client.use_minimal_preference
    client.set_bearer_token(bearer_token)
  end
end

#sort_bundle(bundle, field, order = :asc) ⇒ Object (protected)

Sort the FHIR::Bundle entries on a given field and sort order. If a field is not present, that entry is sorted to the end.

Parameters:

  • bundle (FHIR::Bundle)

    the bundle to sort

  • field (Symbol, String)

    the field to sort on (supports nested fields with dot notation)

  • order (Symbol) (defaults to: :asc)

    the sort order, :asc (default) or :desc



350
351
352
353
354
355
# File 'lib/medical_records/client.rb', line 350

def sort_bundle(bundle, field, order = :asc)
  field = field.to_s
  sort_bundle_with_criteria(bundle, order) do |resource|
    fetch_nested_value(resource, field)
  end
end

#sort_bundle_with_criteria(bundle, order = :asc) ⇒ Object (protected)

Sort the FHIR::Bundle entries based on a provided block. The block should handle different resource types and define how to extract the sorting value from each.

Parameters:

  • bundle (FHIR::Bundle)

    the bundle to sort

  • order (Symbol) (defaults to: :asc)

    the sort order, :asc (default) or :desc



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/medical_records/client.rb', line 364

def sort_bundle_with_criteria(bundle, order = :asc)
  sorted_entries = bundle.entry.sort do |entry1, entry2|
    value1 = yield(entry1.resource)
    value2 = yield(entry2.resource)
    if value2.nil?
      -1
    elsif value1.nil?
      1
    else
      order == :asc ? value1 <=> value2 : value2 <=> value1
    end
  end
  bundle.entry = sorted_entries
  bundle
end

#sort_lab_entries(entries) ⇒ Object (protected)

Sort the FHIR::Bundle entries for lab & test results in reverse chronological order. Different entry types use different date fields for sorting.

Parameters:

  • entries

    a list of FHIR objects



398
399
400
401
402
403
404
405
406
407
408
409
# File 'lib/medical_records/client.rb', line 398

def sort_lab_entries(entries)
  entries.sort_by! do |entry|
    case entry
    when FHIR::DiagnosticReport
      -(entry.effectiveDateTime&.to_i || 0)
    when FHIR::DocumentReference
      -(entry.date&.to_i || 0)
    else
      0
    end
  end
end