diff --git a/README.md b/README.md index 33ab8f0..cdaff87 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,49 @@ collection.data.each do |member| end ``` +### Search Members + +Search for members within a program. Returns a bounded result set (max 10 results) with a `more_results` flag indicating if additional matches exist. + +Valid parameter combinations: +- `born_on` + `employee_id` +- `born_on` + `first_name` + `last_name` +- `born_on` + `first_name` + `last_name` + `employee_id` +- `born_on` + `first_name_prefix` + `last_name_prefix` +- `born_on` + `first_name_prefix` + `last_name_prefix` + `employee_id` + +```ruby +# Search by employee ID and DOB +result = client.programs('program-id').search_members( + born_on: '1980-01-15', + employee_id: 'EMP123' +) + +result[:data].each do |member| + puts "#{member[:first_name]} #{member[:last_name]}" +end + +puts "More results available" if result[:more_results] + +# Search by name and DOB +result = client.programs('program-id').search_members( + born_on: '1980-01-15', + first_name: 'George', + last_name: 'Washington' +) + +# Search by name prefix and DOB +result = client.programs('program-id').search_members( + born_on: '1980-01-15', + first_name_prefix: 'G', + last_name_prefix: 'Was' +) +``` + +Note: Unlike `list`, `search_members` does not support pagination. It returns up to 10 results with a `more_results` boolean. An `ArgumentError` will be raised if an invalid parameter combination is provided. + +Note: Depending on your API key, `search_members` may be the only method you have access to. Contact your DataNexus representative for more information about your API key's permissions. + ### Pagination ```ruby diff --git a/lib/data_nexus/resources/programs.rb b/lib/data_nexus/resources/programs.rb index 995f19b..98230a5 100644 --- a/lib/data_nexus/resources/programs.rb +++ b/lib/data_nexus/resources/programs.rb @@ -19,7 +19,18 @@ module Resources # client.programs("program-uuid").members("member-id").consents.create(...) # client.programs("program-uuid").members("member-id").enrollments.create(...) # + # @example Search for members + # client.programs("program-uuid").search_members(born_on: "1976-07-04", employee_id: "ABC123") + # class Programs + VALID_SEARCH_COMBINATIONS = [ + %i[born_on first_name last_name employee_id], + %i[born_on first_name last_name], + %i[born_on first_name_prefix last_name_prefix employee_id], + %i[born_on first_name_prefix last_name_prefix], + %i[born_on employee_id] + ].freeze + # @return [Connection] The HTTP connection attr_reader :connection @@ -65,6 +76,71 @@ def members(member_id = nil) ProgramMembers.new(connection, program_id) end end + + # Search for members within this program + # + # Returns a bounded result set (max 10 results). Use `more_results` to + # determine if additional matches exist beyond what was returned. + # + # Valid parameter combinations: + # - born_on, first_name, last_name, employee_id + # - born_on, first_name, last_name + # - born_on, first_name_prefix, last_name_prefix, employee_id + # - born_on, first_name_prefix, last_name_prefix + # - born_on, employee_id + # + # @param born_on [String] Date of birth (YYYY-MM-DD) - required for all searches + # @param first_name [String, nil] Exact first name match + # @param first_name_prefix [String, nil] First name prefix (min 1 char) + # @param last_name [String, nil] Exact last name match + # @param last_name_prefix [String, nil] Last name prefix (min 3 chars) + # @param employee_id [String, nil] Employee ID + # + # @return [Hash] Response with :data (Array) and :more_results (Boolean) + # + # @raise [ArgumentError] If params don't match a valid search combination + # + # @example Search by name and DOB + # client.programs("uuid").search_members( + # born_on: "1976-07-04", + # first_name: "george", + # last_name: "washington" + # ) + # + # @example Search by prefix and DOB + # client.programs("uuid").search_members( + # born_on: "1976-07-04", + # first_name_prefix: "g", + # last_name_prefix: "was" + # ) + # + # @example Search by employee ID and DOB + # client.programs("uuid").search_members( + # born_on: "1976-07-04", + # employee_id: "ABC1234" + # ) + def search_members(**params) + validate_search_params!(params) + + connection.post("/api/programs/#{program_id}/members/search", params) + end + + private + + def validate_search_params!(params) + provided_keys = params.keys.sort + + return if VALID_SEARCH_COMBINATIONS.any? { |combo| combo.sort == provided_keys } + + raise ArgumentError, invalid_search_params_message(provided_keys) + end + + def invalid_search_params_message(provided_keys) + valid_combos = VALID_SEARCH_COMBINATIONS.map { |c| c.join(', ') }.join("\n - ") + + "Invalid search parameter combination: #{provided_keys.join(', ')}. " \ + "Valid combinations are:\n - #{valid_combos}" + end end end end diff --git a/spec/cassettes/program_members/search_employee_id.yml b/spec/cassettes/program_members/search_employee_id.yml new file mode 100644 index 0000000..911f5b5 --- /dev/null +++ b/spec/cassettes/program_members/search_employee_id.yml @@ -0,0 +1,53 @@ +--- +http_interactions: + - request: + method: post + uri: https://localhost:4000/api/programs//members/search + body: + encoding: UTF-8 + string: '{"born_on":"","employee_id":""}' + headers: + Authorization: + - apikey + Content-Type: + - application/json + Accept: + - application/json + User-Agent: + - data-nexus-ruby/0.1.0 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Content-Length: + - "5240" + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 03 Feb 2026 20:50:32 GMT + Server: + - Cowboy + X-Request-Id: + - GJDXtohbavv3IpEAABpi + body: + encoding: UTF-8 + string: + '{"data":[{"id":"","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[{"id":2,"expires_at":"2025-01-01T00:00:00Z","enrolled_at":"2024-01-01T00:00:00.000000Z","enrollment_events":[],"program_id":"","member_id":""}],"consents":[{"id":5,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":6,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":7,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":1,"category":"sms","consent_details":{},"member_response":true,"consented_at":"2025-10-08T20:18:08Z","program_id":"","member_id":""},{"id":4,"category":"hipaa","consent_details":{},"member_response":true,"consented_at":"2025-10-08T20:18:10Z","program_id":"","member_id":""}],"first_name":"Betty + Jo","middle_name":"","last_name":"Brown","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":"+15551234567","eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"878787871","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"878787871","parent_sponsor_member_identifier":"878787871","relationship_type":"PP","insurance_identifiers":[]},{"id":"0mw7m","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[],"consents":[],"first_name":"Olivia","middle_name":"","last_name":"O''Bannon","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":null,"eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"178787874","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"178787874","parent_sponsor_member_identifier":"178787874","relationship_type":"PP","insurance_identifiers":[]},{"id":"jmjXE","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[],"consents":[],"first_name":"Xavier","middle_name":"","last_name":"Xanadu","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":null,"eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"578787874","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"578787874","parent_sponsor_member_identifier":"578787874","relationship_type":"PP","insurance_identifiers":[]},{"id":"2rZd3","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[],"consents":[],"first_name":"Grace","middle_name":"","last_name":"Gammon","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":null,"eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"278787874","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"278787874","parent_sponsor_member_identifier":"278787874","relationship_type":"PP","insurance_identifiers":[]},{"id":"3WvME","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[],"consents":[],"first_name":"Percy","middle_name":"","last_name":"Price","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":null,"eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"378787874","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"378787874","parent_sponsor_member_identifier":"378787874","relationship_type":"PP","insurance_identifiers":[]}],"more_results":false}' + recorded_at: Tue, 03 Feb 2026 20:50:32 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/cassettes/program_members/search_name.yml b/spec/cassettes/program_members/search_name.yml new file mode 100644 index 0000000..70c4b39 --- /dev/null +++ b/spec/cassettes/program_members/search_name.yml @@ -0,0 +1,45 @@ +--- +http_interactions: + - request: + method: post + uri: https://localhost:4000/api/programs//members/search + body: + encoding: UTF-8 + string: '{"born_on":"","first_name":"Betty Jo","last_name":"Brown"}' + headers: + Authorization: + - apikey + Content-Type: + - application/json + Accept: + - application/json + User-Agent: + - data-nexus-ruby/0.1.0 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Content-Length: + - "1985" + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 03 Feb 2026 20:50:32 GMT + Server: + - Cowboy + X-Request-Id: + - GJDXtpug-B8qhTQAABsC + body: + encoding: UTF-8 + string: + '{"data":[{"id":"","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[{"id":2,"expires_at":"2025-01-01T00:00:00Z","enrolled_at":"2024-01-01T00:00:00.000000Z","enrollment_events":[],"program_id":"","member_id":""}],"consents":[{"id":5,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":6,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":7,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":1,"category":"sms","consent_details":{},"member_response":true,"consented_at":"2025-10-08T20:18:08Z","program_id":"","member_id":""},{"id":4,"category":"hipaa","consent_details":{},"member_response":true,"consented_at":"2025-10-08T20:18:10Z","program_id":"","member_id":""}],"first_name":"Betty + Jo","middle_name":"","last_name":"Brown","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":"+15551234567","eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"878787871","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"878787871","parent_sponsor_member_identifier":"878787871","relationship_type":"PP","insurance_identifiers":[]}],"more_results":false}' + recorded_at: Tue, 03 Feb 2026 20:50:33 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/cassettes/program_members/search_prefix.yml b/spec/cassettes/program_members/search_prefix.yml new file mode 100644 index 0000000..8f9c940 --- /dev/null +++ b/spec/cassettes/program_members/search_prefix.yml @@ -0,0 +1,45 @@ +--- +http_interactions: + - request: + method: post + uri: https://localhost:4000/api/programs//members/search + body: + encoding: UTF-8 + string: '{"born_on":"","first_name_prefix":"Bet","last_name_prefix":"Bro"}' + headers: + Authorization: + - apikey + Content-Type: + - application/json + Accept: + - application/json + User-Agent: + - data-nexus-ruby/0.1.0 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Content-Length: + - "1985" + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 03 Feb 2026 20:50:32 GMT + Server: + - Cowboy + X-Request-Id: + - GJDXtq77PBWLg0UAABsi + body: + encoding: UTF-8 + string: + '{"data":[{"id":"","state":"CO","email":"test21@emsanacare.com","option_id":null,"gender":"female","enrollments":[{"id":2,"expires_at":"2025-01-01T00:00:00Z","enrolled_at":"2024-01-01T00:00:00.000000Z","enrollment_events":[],"program_id":"","member_id":""}],"consents":[{"id":5,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":6,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":7,"category":"sms","consent_details":{"sms_phone_number":"+15558675309"},"member_response":true,"consented_at":null,"program_id":"","member_id":""},{"id":1,"category":"sms","consent_details":{},"member_response":true,"consented_at":"2025-10-08T20:18:08Z","program_id":"","member_id":""},{"id":4,"category":"hipaa","consent_details":{},"member_response":true,"consented_at":"2025-10-08T20:18:10Z","program_id":"","member_id":""}],"first_name":"Betty + Jo","middle_name":"","last_name":"Brown","born_on":"","street_address_1":"401 + Shady Holw","street_address_2":null,"city":"Nederland","postal_code":"80466","phone_number":"+15551234567","eligibility_start_on":"2024-01-01","eligibility_end_on":null,"sponsor_member_identifier":"878787871","soda_sponsor_id":null,"employee_id":"","health_plan_coverage_tier":"You + Only","health_plan_description":"Aetna, Inc.","emails":[{"type":"personal","value":"test21@emsanacare.com"},{"type":"primary_member","value":null}],"work_email":null,"health_plan_identifier":"878787871","parent_sponsor_member_identifier":"878787871","relationship_type":"PP","insurance_identifiers":[]}],"more_results":false}' + recorded_at: Tue, 03 Feb 2026 20:50:33 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/integration/program_members_spec.rb b/spec/integration/program_members_spec.rb index 36eeab5..7d34e34 100644 --- a/spec/integration/program_members_spec.rb +++ b/spec/integration/program_members_spec.rb @@ -75,4 +75,68 @@ expect(household).to be_an(Array) end end + + describe 'searching members' do + it 'returns members matching employee_id and DOB', vcr: { cassette_name: 'program_members/search_employee_id' } do + result = client.programs(program_id).search_members( + born_on: test_born_on, + employee_id: test_employee_id + ) + + expect(result).to be_a(Hash) + expect(result[:data]).to be_an(Array) + expect(result).to have_key(:more_results) + end + + it 'returns members matching name and DOB', vcr: { cassette_name: 'program_members/search_name' } do + result = client.programs(program_id).search_members( + born_on: test_born_on, + first_name: 'Betty Jo', + last_name: 'Brown' + ) + + expect(result).to be_a(Hash) + expect(result[:data]).to be_an(Array) + expect(result).to have_key(:more_results) + end + + it 'returns members matching name prefix and DOB', vcr: { cassette_name: 'program_members/search_prefix' } do + result = client.programs(program_id).search_members( + born_on: test_born_on, + first_name_prefix: 'Bet', + last_name_prefix: 'Bro' + ) + + expect(result).to be_a(Hash) + expect(result[:data]).to be_an(Array) + expect(result).to have_key(:more_results) + end + + it 'raises ArgumentError for invalid parameter combinations' do + expect do + client.programs(program_id).search_members( + born_on: test_born_on, + first_name: 'George' + ) + end.to raise_error(ArgumentError, /Invalid search parameter combination/) + end + + it 'raises ArgumentError when mixing prefix and exact name params' do + expect do + client.programs(program_id).search_members( + born_on: test_born_on, + first_name: 'George', + last_name_prefix: 'Was' + ) + end.to raise_error(ArgumentError, /Invalid search parameter combination/) + end + + it 'raises ArgumentError when born_on is missing' do + expect do + client.programs(program_id).search_members( + employee_id: test_employee_id + ) + end.to raise_error(ArgumentError, /Invalid search parameter combination/) + end + end end diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index f183392..bcfdaa4 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -20,6 +20,7 @@ # Match requests on method and path only (ignore query params for flexibility) config.default_cassette_options = { record: :once, - match_requests_on: %i[method path] + match_requests_on: %i[method path], + allow_playback_repeats: true } end