hln_ice 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad2a04435e36bc2e843e01c674c067f8d552f7b528e95e5540b19cbb11a06ff4
4
- data.tar.gz: c5a5769cbf8c2d41e7074812a1ff83e0caf489a9a31d3bf4f2486a2160674118
3
+ metadata.gz: 80a72761b4c53974d8784c926ca86d27953488ac8c6e99e9bdb386447b8a3f98
4
+ data.tar.gz: 3952414a0bf97c45cc2a7009c4b16b1a016bb866697821c1bf69c1b60bcc05ac
5
5
  SHA512:
6
- metadata.gz: 0ea75c7a3a922fb8fb76388f3d5d3ae568f324afe744861cc79b74c2eb0d83fbcdbed60ff2839391dd0823ba36cb197101494c147cd690036013ad46ea9ffc54
7
- data.tar.gz: e66a9cd04e3b62d2e47731311dbc1ddc1f57f5a52259f81cc5d3a334fd8f82826ac53ba6e75c3f76630b92b8d54761ae5236127162b2893ec177c7c1469c9f00
6
+ metadata.gz: 2389561c18f1c9d88f486214bf4bcd5eb6b9b3bc92f08b08fc7d6ac360ccc07360646cece3ddde6c26c5a6a5dddb2026bea28f211ce5f700ace3f1baad4179c8
7
+ data.tar.gz: bd197b89a993c6df99f2fad1d586a3da4ce31715388a541f4679ad96a26c2c9635cdebeeef502ba67808fbbb9427c14338bc9fcd0692ca9a84f9ef242128761c
data/.rubocop.yml CHANGED
@@ -1,5 +1,8 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
1
3
  AllCops:
2
4
  TargetRubyVersion: 3.1
5
+ SuggestExtensions: false
3
6
 
4
7
  Style/StringLiterals:
5
8
  Enabled: true
@@ -11,3 +14,15 @@ Style/StringLiteralsInInterpolation:
11
14
 
12
15
  Layout/LineLength:
13
16
  Max: 120
17
+
18
+ # Methods after `private` are indented one level, Rails style.
19
+ Layout/IndentationConsistency:
20
+ EnforcedStyle: indented_internal_methods
21
+
22
+ # With indented private methods, `private` is followed directly by them.
23
+ Layout/EmptyLinesAroundAccessModifier:
24
+ Enabled: false
25
+
26
+ Metrics/BlockLength:
27
+ Exclude:
28
+ - "spec/**/*"
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,43 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
+ # using RuboCop version 1.91.0.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 5
10
+ # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
11
+ Metrics/AbcSize:
12
+ Max: 70
13
+
14
+ # Offense count: 2
15
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
16
+ # AllowedMethods: refine
17
+ Metrics/BlockLength:
18
+ Max: 50
19
+
20
+ # Offense count: 1
21
+ # Configuration parameters: CountComments, CountAsOne.
22
+ Metrics/ClassLength:
23
+ Max: 113
24
+
25
+ # Offense count: 3
26
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
27
+ Metrics/CyclomaticComplexity:
28
+ Max: 15
29
+
30
+ # Offense count: 8
31
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
32
+ Metrics/MethodLength:
33
+ Max: 72
34
+
35
+ # Offense count: 1
36
+ # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
37
+ Metrics/ParameterLists:
38
+ Max: 6
39
+
40
+ # Offense count: 2
41
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
42
+ Metrics/PerceivedComplexity:
43
+ Max: 15
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-10-06
4
+
5
+ - Expose ICE's per-dose evaluations under `evaluations` in the result, with one entry per administered dose and vaccine group it counts toward (so a combination vaccine has several). Each entry has the caller's event id, CVX, administration date, vaccine group, dose number, validity, evaluation status, and reasons. Codes are passed through as ICE returns them. `recommendations` and `simplified_status` are unchanged.
6
+
3
7
  ## [0.3.0] - 2026-09-24
4
8
 
5
9
  - Expose schedule authorities (e.g. `ACIP_CDC`, `AAP`, `AAFP`) on each recommendation under `schedule_authorities` when the ICE server has `outputScheduleAuthorities` enabled. The key is omitted when the server does not return them.
data/README.md CHANGED
@@ -6,7 +6,8 @@ Given a patient's demographics and immunization history, `HlnIce` builds the
6
6
  VMR (Virtual Medical Record) request, calls the ICE evaluation endpoint, and
7
7
  parses the response into:
8
8
 
9
- - a full list of **recommendations** (one per vaccine group, with status, reasons, and administration intervals), and
9
+ - a full list of **recommendations** (one per vaccine group, with status, reasons, and administration intervals),
10
+ - the **evaluations** of each administered dose (per vaccine group, with dose number, validity, and reasons), and
10
11
  - a **simplified status** map keyed by vaccine group — e.g. `{ ipv_opv: "overdue", mmr: "compliant" }`.
11
12
 
12
13
  The gem has no Rails or ActiveSupport dependency; it takes an injected logger
@@ -79,6 +80,7 @@ patient_data = {
79
80
  clinicalStatements: {
80
81
  substanceAdministrationEvents: [
81
82
  {
83
+ id: "1", # echoed back as event_id on evaluations; a UUID is generated if omitted
82
84
  substance: {
83
85
  substanceCode: { code: "10", displayName: "Polio" }
84
86
  },
@@ -116,6 +118,20 @@ On success:
116
118
  ]
117
119
  }
118
120
  ],
121
+ # One entry per administered dose and vaccine group it counts toward, so a
122
+ # combination vaccine has several. Codes are passed through from ICE.
123
+ evaluations: [
124
+ {
125
+ event_id: "1",
126
+ cvx: "10",
127
+ administered_on: "2020-03-01",
128
+ vaccine_group: { code: "400", name: "Polio Vaccine Group" },
129
+ dose_number: 1, # nil if ICE omits it
130
+ valid: true,
131
+ status: { code: "VALID", name: "Valid Dose" }, # e.g. VALID, INVALID, ACCEPTED
132
+ reasons: [] # e.g. [{ code: "BELOW_MINIMUM_INTERVAL", name: "Below Minimum Interval" }]
133
+ }
134
+ ],
119
135
  simplified_status: { ipv_opv: "overdue" }
120
136
  }
121
137
  }
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
3
  require "json"
5
- require "base64"
6
4
  require "logger"
7
- require "securerandom"
8
5
  require "httparty"
9
- require "nokogiri"
10
6
 
11
7
  module HlnIce
12
8
  # Client for the HLN ICE (Immunization Calculation Engine) OpenCDS service.
@@ -14,28 +10,12 @@ module HlnIce
14
10
  # Evaluates a patient's immunization history against the ICE forecasting
15
11
  # rules and returns both the raw recommendations and a simplified status
16
12
  # mapping keyed by vaccine group.
13
+ #
14
+ # All logging happens here: RequestBuilder and ResponseParser never log, so
15
+ # this class is the only place that can write patient data to the logs.
17
16
  class Client
18
- # Mapping from ICE status codes to our status keys
19
- STATUS_MAPPING = {
20
- "CONDITIONAL" => "conditional",
21
- "FUTURE_RECOMMENDED" => "compliant",
22
- "NOT_RECOMMENDED" => "compliant",
23
- "RECOMMENDED" => "overdue",
24
- }.freeze
25
-
26
- # Mapping from ICE vaccine groups to our immunization keys
27
- VACCINE_MAPPING = {
28
- "DTP Vaccine Group" => :dtap_tdap,
29
- "Hep A Vaccine Group" => :hep_a,
30
- "Hep B Vaccine Group" => :hep_b,
31
- "Hib Vaccine Group" => :hib,
32
- "HPV Vaccine Group" => :hpv,
33
- "Meningococcal Vaccine Group" => :mcv4,
34
- "MMR Vaccine Group" => :mmr,
35
- "Pneumococcal Vaccine Group" => :pcv,
36
- "Polio Vaccine Group" => :ipv_opv,
37
- "Varicella Vaccine Group" => :var,
38
- }.freeze
17
+ STATUS_MAPPING = SimplifiedStatus::STATUS_MAPPING
18
+ VACCINE_MAPPING = SimplifiedStatus::VACCINE_MAPPING
39
19
 
40
20
  attr_reader :base_url, :timeout, :max_retries, :retry_delay, :logger, :log_payloads
41
21
 
@@ -62,7 +42,7 @@ module HlnIce
62
42
  )
63
43
 
64
44
  response.success?
65
- rescue => e
45
+ rescue StandardError => e
66
46
  logger.error("ICE service unavailable: #{e.message}")
67
47
  false
68
48
  end
@@ -95,7 +75,7 @@ module HlnIce
95
75
  else
96
76
  handle_error_response(response)
97
77
  end
98
- rescue => e
78
+ rescue StandardError => e
99
79
  retries += 1
100
80
  if retries <= max_retries
101
81
  logger.warn("Retrying ICE request (#{retries}/#{max_retries}): #{e.message}")
@@ -109,157 +89,24 @@ module HlnIce
109
89
  end
110
90
 
111
91
  private
112
- # Plain-Ruby equivalent of ActiveSupport's `blank?`.
113
- def blank?(value)
114
- return true if value.nil?
115
- return value.strip.empty? if value.is_a?(String)
116
- return value.empty? if value.respond_to?(:empty?)
117
-
118
- false
119
- end
120
-
121
- # Plain-Ruby equivalent of ActiveSupport's `present?`.
122
- def present?(value)
123
- !blank?(value)
124
- end
125
-
126
92
  def build_ice_payload(patient_data)
127
- # Extract patient information
128
- patient_id = patient_data[:patient][:id]
129
- date_of_birth = patient_data[:patient][:date_of_birth]
130
- gender = patient_data[:patient][:gender]
131
- gender = "U" if blank?(gender) # Set default to 'U' if blank
93
+ builder = RequestBuilder.new(patient_data)
132
94
 
133
95
  # Log input data for debugging
134
96
  logger.debug("ICE Input Data: #{JSON.pretty_generate(patient_data)}") if log_payloads
135
97
 
136
- # Build the VMR XML
137
- xml_payload = <<~XML
138
- <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
139
- <ns4:cdsInput xmlns:ns2="org.opencds"
140
- xmlns:ns3="org.opencds.vmr.v1_0.schema.vmr"
141
- xmlns:ns4="org.opencds.vmr.v1_0.schema.cdsinput"
142
- xmlns:ns5="org.opencds.vmr.v1_0.schema.cdsoutput">
143
- <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
144
- <cdsContext>
145
- <cdsSystemUserPreferredLanguage code="en" codeSystem="2.16.840.1.113883.6.99" displayName="English"/>
146
- </cdsContext>
147
- <vmrInput>
148
- <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
149
- <patient>
150
- <templateId root="2.16.840.1.113883.3.795.11.2.1.1"/>
151
- <id root="2.16.840.1.113883.3.795.12.100.11" extension="#{patient_id}"/>
152
- <demographics>
153
- <birthTime value="#{format_date_for_ice(date_of_birth)}"/>
154
- <gender code="#{gender}" codeSystem="2.16.840.1.113883.5.1"/>
155
- </demographics>
156
- <clinicalStatements>
157
- <substanceAdministrationEvents>
158
- XML
159
-
160
- # Add immunization records if available
161
- if present?(patient_data[:immunizations])
162
- patient_data[:immunizations].each do |imm|
163
- next unless present?(imm.dig(:clinicalStatements, :substanceAdministrationEvents))
164
-
165
- imm[:clinicalStatements][:substanceAdministrationEvents].each do |event|
166
- next unless present?(event.dig(:substance, :substanceCode))
167
-
168
- substance_code = event[:substance][:substanceCode]
169
-
170
- # Format dates to ICE format (YYYYMMDD)
171
- low_date = format_date_for_ice(event[:administrationTimeInterval][:low])
172
- high_date = format_date_for_ice(event[:administrationTimeInterval][:high])
173
-
174
- xml_payload += <<~XML
175
- <substanceAdministrationEvent>
176
- <templateId root="2.16.840.1.113883.3.795.11.9.1.1"/>
177
- <id root="#{event[:id] || SecureRandom.uuid}"/>
178
- <substanceAdministrationGeneralPurpose code="384810002" codeSystem="2.16.840.1.113883.6.5"/>
179
- <substance>
180
- <id root="#{event[:substance][:id] || SecureRandom.uuid}"/>
181
- <substanceCode code="#{substance_code[:code]}"
182
- codeSystem="2.16.840.1.113883.12.292"
183
- displayName="#{substance_code[:displayName]}"
184
- originalText="#{substance_code[:displayName]}"/>
185
- </substance>
186
- <administrationTimeInterval low="#{low_date}" high="#{high_date}"/>
187
- </substanceAdministrationEvent>
188
- XML
189
- end
190
- end
191
- end
192
-
193
- # Close the XML
194
- xml_payload += <<~XML
195
- </substanceAdministrationEvents>
196
- </clinicalStatements>
197
- </patient>
198
- </vmrInput>
199
- </ns4:cdsInput>
200
- XML
201
-
202
98
  # Log the generated XML for debugging
203
- logger.debug("Generated ICE XML: #{xml_payload}") if log_payloads
99
+ logger.debug("Generated ICE XML: #{builder.xml}") if log_payloads
204
100
 
205
- # Base64 encode the XML
206
- base64_encoded_payload = Base64.strict_encode64(xml_payload)
207
-
208
- # Build the full payload
209
- {
210
- "interactionId" => {
211
- "scopingEntityId" => "org.nyc.cir",
212
- "interactionId" => "#{patient_id}-#{Time.now.to_i}",
213
- "submissionTime" => (Time.now.to_f * 1000).to_i
214
- },
215
- "evaluationRequest" => {
216
- "clientLanguage" => "en",
217
- "clientTimeZoneOffset" => "+0000",
218
- "kmEvaluationRequest" => [
219
- {
220
- "kmId" => {
221
- "scopingEntityId" => "org.nyc.cir",
222
- "businessId" => "ICE",
223
- "version" => "1.0.0"
224
- }
225
- }
226
- ],
227
- "dataRequirementItemData" => [
228
- {
229
- "driId" => {
230
- "containingEntityId" => {
231
- "scopingEntityId" => "org.nyc.cir",
232
- "businessId" => "ICEData",
233
- "version" => "1.0.0"
234
- },
235
- "itemId" => "cdsPayload"
236
- },
237
- "data" => {
238
- "informationModelSSId" => {
239
- "scopingEntityId" => "org.opencds.vmr",
240
- "businessId" => "VMR",
241
- "version" => "1.0"
242
- },
243
- "base64EncodedPayload" => [base64_encoded_payload]
244
- }
245
- }
246
- ]
247
- }
248
- }
101
+ builder.payload
249
102
  end
250
103
 
251
104
  def parse_ice_response(response_body)
252
105
  logger.debug("ICE service response: #{response_body}") if log_payloads
253
106
 
254
- result = JSON.parse(response_body)
255
-
256
- if result.dig("finalKMEvaluationResponse", 0, "kmEvaluationResultData", 0, "data", "base64EncodedPayload", 0)
257
- base64_result = result["finalKMEvaluationResponse"][0]["kmEvaluationResultData"][0]["data"]["base64EncodedPayload"][0]
258
- decoded_result = Base64.decode64(base64_result)
259
-
260
- # Parse the XML response
261
- parsed_result = parse_xml_response(decoded_result)
107
+ parsed_result = ResponseParser.new(response_body).data
262
108
 
109
+ if parsed_result
263
110
  {
264
111
  success: true,
265
112
  data: parsed_result
@@ -271,210 +118,23 @@ module HlnIce
271
118
  error: "No valid response found in ICE output."
272
119
  }
273
120
  end
274
- rescue => e
121
+ rescue StandardError => e
275
122
  logger.error("Error parsing ICE response: #{e.message}")
276
123
  { success: false, error: "Error parsing ICE response: #{e.message}" }
277
124
  end
278
125
 
279
- def parse_xml_response(xml_string)
280
- # Parse the XML
281
- doc = Nokogiri::XML(xml_string)
282
-
283
- # Remove namespaces to simplify parsing
284
- doc.remove_namespaces!
285
-
286
- # Extract patient information
287
- patient = doc.xpath("//patient").first
288
- return { raw_xml: xml_string } unless patient
289
-
290
- patient_id = patient.xpath("./id").first["extension"] rescue nil
291
- birth_time = patient.xpath("./demographics/birthTime").first["value"] rescue nil
292
- gender = patient.xpath("./demographics/gender").first["code"] rescue nil
293
-
294
- # Format birth date if present
295
- birth_date = format_ice_date(birth_time)
296
-
297
- # Extract vaccine recommendations
298
- all_recommendations = []
299
-
300
- doc.xpath("//substanceAdministrationProposal").each do |proposal|
301
- # Get the substance code and name
302
- substance_element = proposal.xpath("./substance/substanceCode").first
303
- next unless substance_element
304
-
305
- vaccine_code = substance_element["code"]
306
- vaccine_name = substance_element["displayName"]
307
- vaccine_code_system = substance_element["codeSystem"]
308
-
309
- # Get the clinical observation result carrying the recommendation status.
310
- # A proposal may contain other observation results (e.g. schedule
311
- # authorities), in any order, so select the one with a status concept.
312
- observation = proposal.xpath(".//observationResult[observationValue/concept]").first
313
- next unless observation
314
-
315
- # Get recommendation status
316
- status_element = observation.xpath(".//observationValue/concept").first
317
-
318
- status_code = status_element["code"]
319
- status_name = status_element["displayName"]
320
-
321
- # Get interpretation reasons
322
- reasons = []
323
- observation.xpath(".//interpretation").each do |interpretation|
324
- reason = {
325
- code: interpretation["code"],
326
- name: interpretation["displayName"]
327
- }
328
-
329
- # Add supplemental text if available
330
- if interpretation["originalText"]
331
- reason[:supplemental_text] = interpretation["originalText"]
332
- end
333
-
334
- reasons << reason
335
- end
336
-
337
- # Get administration time intervals if available
338
- proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
339
- valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
340
-
341
- intervals = {}
342
- if proposed_interval
343
- intervals[:proposed] = {
344
- low: format_ice_date(proposed_interval["low"]),
345
- high: format_ice_date(proposed_interval["high"])
346
- }.compact
347
- end
348
-
349
- if valid_interval
350
- intervals[:valid] = {
351
- low: format_ice_date(valid_interval["low"]),
352
- high: format_ice_date(valid_interval["high"])
353
- }.compact
354
- end
355
-
356
- # Build the recommendation object
357
- recommendation = {
358
- vaccine: {
359
- code: vaccine_code,
360
- code_system: vaccine_code_system,
361
- name: vaccine_name
362
- },
363
- status: {
364
- code: status_code,
365
- name: status_name
366
- },
367
- reasons:
368
- }
369
-
370
- # Only add intervals if they exist
371
- recommendation[:intervals] = intervals if present?(intervals)
372
-
373
- # Only add schedule authorities if the service returned them
374
- # (requires the ICE outputScheduleAuthorities property)
375
- schedule_authorities = parse_schedule_authorities(proposal)
376
- recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
377
-
378
- all_recommendations << recommendation
379
- end
380
-
381
- # Create simplified immunization status mapping
382
- simplified_status = {}
383
-
384
- # Process all recommendations to create the simplified mapping
385
- all_recommendations.each do |recommendation|
386
- vaccine_name = recommendation[:vaccine][:name]
387
- status_code = recommendation[:status][:code]
388
-
389
- # Map the vaccine name to our key
390
- vaccine_key = VACCINE_MAPPING[vaccine_name]
391
- next unless vaccine_key # Skip if we don't have a mapping
392
-
393
- # Map the status code to our status
394
- status = STATUS_MAPPING[status_code] || "overdue" # Default to overdue if unknown
395
-
396
- # Special handling for conditional status based on reasons
397
- if status == "conditional"
398
- # Check if any reason indicates medical exemption
399
- has_medical_exemption = recommendation[:reasons].any? { |r| r[:code] == "MEDICAL_EXEMPTION" }
400
- if has_medical_exemption
401
- status = "medically_exempt"
402
- end
403
- end
404
-
405
- # Store in our simplified mapping
406
- simplified_status[vaccine_key] = status
407
- end
408
-
409
- # Build the final result structure
410
- {
411
- raw_xml: xml_string,
412
- patient: {
413
- id: patient_id,
414
- birth_date: birth_date || birth_time,
415
- gender:
416
- }.compact,
417
- recommendations: all_recommendations, # Keep the original flat list for backward compatibility
418
- simplified_status: # Add the simplified mapping
419
- }
420
- end
421
-
422
- # Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
423
- # Returns an empty array when the service did not include them.
424
- def parse_schedule_authorities(proposal)
425
- authorities = proposal.xpath(
426
- ".//observationResult[observationFocus/@code='ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES']/interpretation"
427
- )
428
-
429
- authorities.map do |interpretation|
430
- {
431
- code: interpretation["code"],
432
- name: interpretation["displayName"]
433
- }
434
- end
435
- end
436
-
437
126
  def handle_error_response(response)
438
127
  error_message = "ICE service error: #{response.code}"
439
128
 
440
129
  begin
441
130
  error_details = JSON.parse(response.body)
442
- error_message += " - #{error_details['message'] || error_details['error'] || response.body}"
443
- rescue
131
+ error_message += " - #{error_details["message"] || error_details["error"] || response.body}"
132
+ rescue StandardError
444
133
  error_message += " - #{response.body}"
445
134
  end
446
135
 
447
136
  logger.error(error_message)
448
137
  { success: false, error: error_message }
449
138
  end
450
-
451
- # Helper method to format ICE dates to standard format
452
- def format_ice_date(ice_date)
453
- return nil unless ice_date
454
-
455
- # ICE dates are in format: YYYYMMDDHHMMSS.000+0000
456
- if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
457
- year = $1
458
- month = $2
459
- day = $3
460
- "#{year}-#{month}-#{day}"
461
- else
462
- ice_date
463
- end
464
- end
465
-
466
- # Helper method to format dates for ICE (YYYYMMDD)
467
- def format_date_for_ice(date_string)
468
- return nil unless date_string
469
-
470
- begin
471
- date = Date.parse(date_string)
472
- # Format as YYYYMMDD
473
- date.strftime("%Y%m%d")
474
- rescue
475
- # If parsing fails, try to use the original string
476
- date_string
477
- end
478
- end
479
139
  end
480
140
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Extracts ICE's evaluation of each administered dose from the vMR patient
5
+ # element. ICE nests one substanceAdministrationEvent per vaccine group under
6
+ # each input event, so a combination vaccine yields one evaluation per group
7
+ # it counts toward.
8
+ class EvaluationsParser
9
+ include Support
10
+
11
+ def initialize(patient)
12
+ @patient = patient
13
+ end
14
+
15
+ def evaluations
16
+ events = patient.xpath("./clinicalStatements/substanceAdministrationEvents/substanceAdministrationEvent")
17
+
18
+ events.flat_map do |event|
19
+ event_id = event.xpath("./id").first&.[]("root")
20
+ cvx = event.xpath("./substance/substanceCode").first&.[]("code")
21
+
22
+ event.xpath("./relatedClinicalStatement/substanceAdministrationEvent").filter_map do |component|
23
+ parse_evaluation(component, event_id:, cvx:)
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+ attr_reader :patient
30
+
31
+ # Build the evaluation for one vaccine group from a nested event.
32
+ # Returns nil when the event has no evaluation status observation.
33
+ def parse_evaluation(component, event_id:, cvx:)
34
+ observation = component.xpath("./relatedClinicalStatement/observationResult[observationValue/concept]").first
35
+ return unless observation
36
+
37
+ focus = observation.xpath("./observationFocus").first
38
+ status = observation.xpath("./observationValue/concept").first
39
+ dose_number = component.xpath("./doseNumber").first&.[]("value")
40
+ valid = component.xpath("./isValid").first&.[]("value")
41
+
42
+ {
43
+ event_id:,
44
+ cvx:,
45
+ administered_on: format_ice_date(component.xpath("./administrationTimeInterval").first&.[]("low")),
46
+ vaccine_group: { code: focus&.[]("code"), name: focus&.[]("displayName") },
47
+ dose_number: dose_number&.to_i,
48
+ valid: valid.nil? ? nil : valid == "true",
49
+ status: { code: status["code"], name: status["displayName"] },
50
+ reasons: observation.xpath("./interpretation").map do |interpretation|
51
+ { code: interpretation["code"], name: interpretation["displayName"] }
52
+ end
53
+ }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "securerandom"
5
+
6
+ module HlnIce
7
+ # Builds the ICE evaluate request for a patient: the vMR XML, and the JSON
8
+ # payload that carries it base64 encoded.
9
+ #
10
+ # Does no logging; the Client decides what may be logged.
11
+ class RequestBuilder
12
+ include Support
13
+
14
+ def initialize(patient_data)
15
+ @patient_data = patient_data
16
+
17
+ # Extract patient information
18
+ @patient_id = patient_data[:patient][:id]
19
+ @date_of_birth = patient_data[:patient][:date_of_birth]
20
+ @gender = patient_data[:patient][:gender]
21
+ @gender = "U" if blank?(@gender) # Set default to 'U' if blank
22
+ end
23
+
24
+ def xml
25
+ @xml ||= build_xml
26
+ end
27
+
28
+ def payload
29
+ # Base64 encode the XML
30
+ base64_encoded_payload = Base64.strict_encode64(xml)
31
+
32
+ # Build the full payload
33
+ {
34
+ "interactionId" => {
35
+ "scopingEntityId" => "org.nyc.cir",
36
+ "interactionId" => "#{patient_id}-#{Time.now.to_i}",
37
+ "submissionTime" => (Time.now.to_f * 1000).to_i
38
+ },
39
+ "evaluationRequest" => {
40
+ "clientLanguage" => "en",
41
+ "clientTimeZoneOffset" => "+0000",
42
+ "kmEvaluationRequest" => [
43
+ {
44
+ "kmId" => {
45
+ "scopingEntityId" => "org.nyc.cir",
46
+ "businessId" => "ICE",
47
+ "version" => "1.0.0"
48
+ }
49
+ }
50
+ ],
51
+ "dataRequirementItemData" => [
52
+ {
53
+ "driId" => {
54
+ "containingEntityId" => {
55
+ "scopingEntityId" => "org.nyc.cir",
56
+ "businessId" => "ICEData",
57
+ "version" => "1.0.0"
58
+ },
59
+ "itemId" => "cdsPayload"
60
+ },
61
+ "data" => {
62
+ "informationModelSSId" => {
63
+ "scopingEntityId" => "org.opencds.vmr",
64
+ "businessId" => "VMR",
65
+ "version" => "1.0"
66
+ },
67
+ "base64EncodedPayload" => [base64_encoded_payload]
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ end
74
+
75
+ private
76
+ attr_reader :patient_data, :patient_id, :date_of_birth, :gender
77
+
78
+ def build_xml
79
+ # Build the VMR XML
80
+ xml_payload = <<~XML
81
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
82
+ <ns4:cdsInput xmlns:ns2="org.opencds"
83
+ xmlns:ns3="org.opencds.vmr.v1_0.schema.vmr"
84
+ xmlns:ns4="org.opencds.vmr.v1_0.schema.cdsinput"
85
+ xmlns:ns5="org.opencds.vmr.v1_0.schema.cdsoutput">
86
+ <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
87
+ <cdsContext>
88
+ <cdsSystemUserPreferredLanguage code="en" codeSystem="2.16.840.1.113883.6.99" displayName="English"/>
89
+ </cdsContext>
90
+ <vmrInput>
91
+ <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
92
+ <patient>
93
+ <templateId root="2.16.840.1.113883.3.795.11.2.1.1"/>
94
+ <id root="2.16.840.1.113883.3.795.12.100.11" extension="#{patient_id}"/>
95
+ <demographics>
96
+ <birthTime value="#{format_date_for_ice(date_of_birth)}"/>
97
+ <gender code="#{gender}" codeSystem="2.16.840.1.113883.5.1"/>
98
+ </demographics>
99
+ <clinicalStatements>
100
+ <substanceAdministrationEvents>
101
+ XML
102
+
103
+ # Add immunization records if available
104
+ if present?(patient_data[:immunizations])
105
+ patient_data[:immunizations].each do |imm|
106
+ next unless present?(imm.dig(:clinicalStatements, :substanceAdministrationEvents))
107
+
108
+ imm[:clinicalStatements][:substanceAdministrationEvents].each do |event|
109
+ next unless present?(event.dig(:substance, :substanceCode))
110
+
111
+ substance_code = event[:substance][:substanceCode]
112
+
113
+ # Format dates to ICE format (YYYYMMDD)
114
+ low_date = format_date_for_ice(event[:administrationTimeInterval][:low])
115
+ high_date = format_date_for_ice(event[:administrationTimeInterval][:high])
116
+
117
+ xml_payload += <<~XML
118
+ <substanceAdministrationEvent>
119
+ <templateId root="2.16.840.1.113883.3.795.11.9.1.1"/>
120
+ <id root="#{event[:id] || SecureRandom.uuid}"/>
121
+ <substanceAdministrationGeneralPurpose code="384810002" codeSystem="2.16.840.1.113883.6.5"/>
122
+ <substance>
123
+ <id root="#{event[:substance][:id] || SecureRandom.uuid}"/>
124
+ <substanceCode code="#{substance_code[:code]}"
125
+ codeSystem="2.16.840.1.113883.12.292"
126
+ displayName="#{substance_code[:displayName]}"
127
+ originalText="#{substance_code[:displayName]}"/>
128
+ </substance>
129
+ <administrationTimeInterval low="#{low_date}" high="#{high_date}"/>
130
+ </substanceAdministrationEvent>
131
+ XML
132
+ end
133
+ end
134
+ end
135
+
136
+ # Close the XML
137
+ xml_payload + <<~XML
138
+ </substanceAdministrationEvents>
139
+ </clinicalStatements>
140
+ </patient>
141
+ </vmrInput>
142
+ </ns4:cdsInput>
143
+ XML
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "nokogiri"
6
+
7
+ module HlnIce
8
+ # Parses the body of an ICE evaluate response into the result data:
9
+ # patient, recommendations, evaluations, and simplified status.
10
+ #
11
+ # Does no logging; the Client decides what may be logged.
12
+ class ResponseParser
13
+ include Support
14
+
15
+ def initialize(response_body)
16
+ @response_body = response_body
17
+ end
18
+
19
+ # Returns nil when the response carries no vMR payload. Raises if the
20
+ # body cannot be parsed.
21
+ def data
22
+ result = JSON.parse(response_body)
23
+
24
+ base64_result = result.dig("finalKMEvaluationResponse", 0, "kmEvaluationResultData", 0, "data",
25
+ "base64EncodedPayload", 0)
26
+ return unless base64_result
27
+
28
+ # Parse the XML response
29
+ parse_xml_response(Base64.decode64(base64_result))
30
+ end
31
+
32
+ private
33
+ attr_reader :response_body
34
+
35
+ def parse_xml_response(xml_string)
36
+ # Parse the XML
37
+ doc = Nokogiri::XML(xml_string)
38
+
39
+ # Remove namespaces to simplify parsing
40
+ doc.remove_namespaces!
41
+
42
+ # Extract patient information
43
+ patient = doc.xpath("//patient").first
44
+ return { raw_xml: xml_string } unless patient
45
+
46
+ patient_id = patient.xpath("./id").first&.[]("extension")
47
+ birth_time = patient.xpath("./demographics/birthTime").first&.[]("value")
48
+ gender = patient.xpath("./demographics/gender").first&.[]("code")
49
+
50
+ # Format birth date if present
51
+ birth_date = format_ice_date(birth_time)
52
+
53
+ # Extract vaccine recommendations
54
+ all_recommendations = []
55
+
56
+ doc.xpath("//substanceAdministrationProposal").each do |proposal|
57
+ # Get the substance code and name
58
+ substance_element = proposal.xpath("./substance/substanceCode").first
59
+ next unless substance_element
60
+
61
+ vaccine_code = substance_element["code"]
62
+ vaccine_name = substance_element["displayName"]
63
+ vaccine_code_system = substance_element["codeSystem"]
64
+
65
+ # Get the clinical observation result carrying the recommendation status.
66
+ # A proposal may contain other observation results (e.g. schedule
67
+ # authorities), in any order, so select the one with a status concept.
68
+ observation = proposal.xpath(".//observationResult[observationValue/concept]").first
69
+ next unless observation
70
+
71
+ # Get recommendation status
72
+ status_element = observation.xpath(".//observationValue/concept").first
73
+
74
+ status_code = status_element["code"]
75
+ status_name = status_element["displayName"]
76
+
77
+ # Get interpretation reasons
78
+ reasons = []
79
+ observation.xpath(".//interpretation").each do |interpretation|
80
+ reason = {
81
+ code: interpretation["code"],
82
+ name: interpretation["displayName"]
83
+ }
84
+
85
+ # Add supplemental text if available
86
+ reason[:supplemental_text] = interpretation["originalText"] if interpretation["originalText"]
87
+
88
+ reasons << reason
89
+ end
90
+
91
+ # Get administration time intervals if available
92
+ proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
93
+ valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
94
+
95
+ intervals = {}
96
+ if proposed_interval
97
+ intervals[:proposed] = {
98
+ low: format_ice_date(proposed_interval["low"]),
99
+ high: format_ice_date(proposed_interval["high"])
100
+ }.compact
101
+ end
102
+
103
+ if valid_interval
104
+ intervals[:valid] = {
105
+ low: format_ice_date(valid_interval["low"]),
106
+ high: format_ice_date(valid_interval["high"])
107
+ }.compact
108
+ end
109
+
110
+ # Build the recommendation object
111
+ recommendation = {
112
+ vaccine: {
113
+ code: vaccine_code,
114
+ code_system: vaccine_code_system,
115
+ name: vaccine_name
116
+ },
117
+ status: {
118
+ code: status_code,
119
+ name: status_name
120
+ },
121
+ reasons:
122
+ }
123
+
124
+ # Only add intervals if they exist
125
+ recommendation[:intervals] = intervals if present?(intervals)
126
+
127
+ # Only add schedule authorities if the service returned them
128
+ # (requires the ICE outputScheduleAuthorities property)
129
+ schedule_authorities = parse_schedule_authorities(proposal)
130
+ recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
131
+
132
+ all_recommendations << recommendation
133
+ end
134
+
135
+ # Build the final result structure
136
+ {
137
+ raw_xml: xml_string,
138
+ patient: {
139
+ id: patient_id,
140
+ birth_date: birth_date || birth_time,
141
+ gender:
142
+ }.compact,
143
+ recommendations: all_recommendations, # Keep the original flat list for backward compatibility
144
+ evaluations: EvaluationsParser.new(patient).evaluations,
145
+ simplified_status: SimplifiedStatus.new(all_recommendations).to_h
146
+ }
147
+ end
148
+
149
+ # Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
150
+ # Returns an empty array when the service did not include them.
151
+ def parse_schedule_authorities(proposal)
152
+ authorities = proposal.xpath(
153
+ ".//observationResult[observationFocus/@code='ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES']/interpretation"
154
+ )
155
+
156
+ authorities.map do |interpretation|
157
+ {
158
+ code: interpretation["code"],
159
+ name: interpretation["displayName"]
160
+ }
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Maps parsed recommendations to a simplified status keyed by our
5
+ # immunization keys, e.g. { ipv_opv: "overdue", mmr: "compliant" }.
6
+ class SimplifiedStatus
7
+ # Mapping from ICE status codes to our status keys
8
+ STATUS_MAPPING = {
9
+ "CONDITIONAL" => "conditional",
10
+ "FUTURE_RECOMMENDED" => "compliant",
11
+ "NOT_RECOMMENDED" => "compliant",
12
+ "RECOMMENDED" => "overdue"
13
+ }.freeze
14
+
15
+ # Mapping from ICE vaccine groups to our immunization keys
16
+ VACCINE_MAPPING = {
17
+ "DTP Vaccine Group" => :dtap_tdap,
18
+ "Hep A Vaccine Group" => :hep_a,
19
+ "Hep B Vaccine Group" => :hep_b,
20
+ "Hib Vaccine Group" => :hib,
21
+ "HPV Vaccine Group" => :hpv,
22
+ "Meningococcal Vaccine Group" => :mcv4,
23
+ "MMR Vaccine Group" => :mmr,
24
+ "Pneumococcal Vaccine Group" => :pcv,
25
+ "Polio Vaccine Group" => :ipv_opv,
26
+ "Varicella Vaccine Group" => :var
27
+ }.freeze
28
+
29
+ def initialize(recommendations)
30
+ @recommendations = recommendations
31
+ end
32
+
33
+ def to_h
34
+ # Create simplified immunization status mapping
35
+ simplified_status = {}
36
+
37
+ # Process all recommendations to create the simplified mapping
38
+ recommendations.each do |recommendation|
39
+ vaccine_name = recommendation[:vaccine][:name]
40
+ status_code = recommendation[:status][:code]
41
+
42
+ # Map the vaccine name to our key
43
+ vaccine_key = VACCINE_MAPPING[vaccine_name]
44
+ next unless vaccine_key # Skip if we don't have a mapping
45
+
46
+ # Map the status code to our status
47
+ status = STATUS_MAPPING[status_code] || "overdue" # Default to overdue if unknown
48
+
49
+ # Special handling for conditional status based on reasons
50
+ if status == "conditional"
51
+ # Check if any reason indicates medical exemption
52
+ has_medical_exemption = recommendation[:reasons].any? { |r| r[:code] == "MEDICAL_EXEMPTION" }
53
+ status = "medically_exempt" if has_medical_exemption
54
+ end
55
+
56
+ # Store in our simplified mapping
57
+ simplified_status[vaccine_key] = status
58
+ end
59
+
60
+ simplified_status
61
+ end
62
+
63
+ private
64
+ attr_reader :recommendations
65
+ end
66
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module HlnIce
6
+ # Helpers shared by the request builder and response parsers. Included as
7
+ # private instance methods.
8
+ module Support
9
+ private
10
+ # Plain-Ruby equivalent of ActiveSupport's `blank?`.
11
+ def blank?(value)
12
+ return true if value.nil?
13
+ return value.strip.empty? if value.is_a?(String)
14
+ return value.empty? if value.respond_to?(:empty?)
15
+
16
+ false
17
+ end
18
+
19
+ # Plain-Ruby equivalent of ActiveSupport's `present?`.
20
+ def present?(value)
21
+ !blank?(value)
22
+ end
23
+
24
+ # Helper method to format ICE dates to standard format
25
+ def format_ice_date(ice_date)
26
+ return nil unless ice_date
27
+
28
+ # ICE dates are in format: YYYYMMDDHHMMSS.000+0000
29
+ if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
30
+ year = ::Regexp.last_match(1)
31
+ month = ::Regexp.last_match(2)
32
+ day = ::Regexp.last_match(3)
33
+ "#{year}-#{month}-#{day}"
34
+ else
35
+ ice_date
36
+ end
37
+ end
38
+
39
+ # Helper method to format dates for ICE (YYYYMMDD)
40
+ def format_date_for_ice(date_string)
41
+ return nil unless date_string
42
+
43
+ begin
44
+ date = Date.parse(date_string)
45
+ # Format as YYYYMMDD
46
+ date.strftime("%Y%m%d")
47
+ rescue StandardError
48
+ # If parsing fails, try to use the original string
49
+ date_string
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HlnIce
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/hln_ice.rb CHANGED
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "hln_ice/version"
4
+ require_relative "hln_ice/support"
5
+ require_relative "hln_ice/request_builder"
6
+ require_relative "hln_ice/evaluations_parser"
7
+ require_relative "hln_ice/simplified_status"
8
+ require_relative "hln_ice/response_parser"
4
9
  require_relative "hln_ice/client"
5
10
 
6
11
  module HlnIce
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hln_ice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Sohn
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-24 00:00:00.000000000 Z
10
+ date: 2026-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -61,12 +61,18 @@ extra_rdoc_files: []
61
61
  files:
62
62
  - ".rspec"
63
63
  - ".rubocop.yml"
64
+ - ".rubocop_todo.yml"
64
65
  - CHANGELOG.md
65
66
  - LICENSE.txt
66
67
  - README.md
67
68
  - Rakefile
68
69
  - lib/hln_ice.rb
69
70
  - lib/hln_ice/client.rb
71
+ - lib/hln_ice/evaluations_parser.rb
72
+ - lib/hln_ice/request_builder.rb
73
+ - lib/hln_ice/response_parser.rb
74
+ - lib/hln_ice/simplified_status.rb
75
+ - lib/hln_ice/support.rb
70
76
  - lib/hln_ice/version.rb
71
77
  - sig/hln_ice.rbs
72
78
  homepage: https://github.com/PrimaryDotHealth/hln_ice
@@ -77,6 +83,7 @@ metadata:
77
83
  homepage_uri: https://github.com/PrimaryDotHealth/hln_ice
78
84
  source_code_uri: https://github.com/PrimaryDotHealth/hln_ice
79
85
  changelog_uri: https://github.com/PrimaryDotHealth/hln_ice/blob/main/CHANGELOG.md
86
+ rubygems_mfa_required: 'true'
80
87
  rdoc_options: []
81
88
  require_paths:
82
89
  - lib