Skip to content

Commit 29c6a5e

Browse files
authored
fix(representatives:update): make address validation more lenient and allow partial updates (#24435)
1 parent 358c2a9 commit 29c6a5e

File tree

4 files changed

+285
-76
lines changed

4 files changed

+285
-76
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Veteran
4+
module AddressPreprocessor
5+
PO_BOX_REGEX = /P\.?O\.?\s*BOX\s*\d+/i
6+
ROOM_SUITE_REGEX = /,?\s*(Room|Rm|Suite|Ste?)\.?\s*\d+/i
7+
8+
module_function
9+
10+
# Mutates a shallow copy of the address hash and returns it
11+
def clean(address)
12+
return address unless address.is_a?(Hash) && address['address_line1'].present?
13+
14+
cleaned = address.dup
15+
line1 = cleaned['address_line1'].to_s.dup
16+
17+
# Extract PO Box to address_line1
18+
if (m = line1.match(PO_BOX_REGEX))
19+
po = m.to_s.strip
20+
cleaned['address_line1'] = po
21+
cleaned['address_line2'] = nil
22+
return cleaned
23+
end
24+
25+
# Extract suite/room to address_line2
26+
address_line, secondary_designator = extract_secondary_designator(line1)
27+
if address_line.blank?
28+
cleaned['address_line1'] = nil
29+
return cleaned
30+
end
31+
32+
cleaned['address_line1'] = address_line
33+
cleaned['address_line2'] = cleaned['address_line2'].presence || secondary_designator
34+
35+
cleaned
36+
end
37+
38+
def extract_secondary_designator(line1)
39+
m = line1.match(ROOM_SUITE_REGEX)
40+
return [line1.strip, nil] unless m
41+
42+
address_line = line1.sub(ROOM_SUITE_REGEX, '').strip
43+
secondary_designator = m.to_s.strip.sub(/^,\s*/, '')
44+
45+
[address_line, secondary_designator]
46+
end
47+
end
48+
end

modules/veteran/app/sidekiq/representatives/update.rb

Lines changed: 87 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,23 @@ def process_rep_data(rep_data)
4545

4646
if rep_data['address_changed']
4747
api_response = get_best_address_candidate(rep_data['address'])
48-
49-
# don't update the record if there is not a valid address with non-zero lat and long at this point
48+
# If address validation fails, log and continue to update non-address fields (email/phone)
5049
if api_response.nil?
51-
return
50+
log_error("Address validation failed for Rep: #{rep_data['id']}. Proceeding to update non-address fields.")
5251
else
5352
address_validation_api_response = api_response
5453
end
5554
end
5655

57-
update_rep_record(rep_data, address_validation_api_response)
56+
updated_flags = update_rep_record(rep_data, address_validation_api_response)
5857
rescue => e
5958
log_error("Update failed for Rep id: #{rep_data}: #{e.message}")
6059
return
6160
end
6261

63-
update_flagged_records(rep_data)
62+
# Update flagged records only for fields that were actually updated
63+
updated_flags['representative_id'] = rep_data['id'] if updated_flags.is_a?(Hash)
64+
update_flagged_records(updated_flags || {})
6465
end
6566

6667
def record_can_be_updated?(rep_data)
@@ -73,16 +74,18 @@ def record_can_be_updated?(rep_data)
7374
def build_validation_address(address)
7475
validation_model = VAProfile::Models::ValidationAddress
7576

77+
cleaned = Veteran::AddressPreprocessor.clean(address)
78+
7679
validation_model.new(
77-
address_pou: address['address_pou'],
78-
address_line1: address['address_line1'],
79-
address_line2: address['address_line2'],
80-
address_line3: address['address_line3'],
81-
city: address['city'],
82-
state_code: address['state']['state_code'],
83-
zip_code: address['zip_code5'],
84-
zip_code_suffix: address['zip_code4'],
85-
country_code_iso3: address['country_code_iso3']
80+
address_pou: cleaned['address_pou'] || address['address_pou'],
81+
address_line1: cleaned['address_line1'],
82+
address_line2: cleaned['address_line2'],
83+
address_line3: cleaned['address_line3'],
84+
city: cleaned['city'] || address['city'],
85+
state_code: cleaned.dig('state', 'state_code') || address.dig('state', 'state_code'),
86+
zip_code: cleaned['zip_code5'] || address['zip_code5'],
87+
zip_code_suffix: cleaned['zip_code4'] || address['zip_code4'],
88+
country_code_iso3: cleaned['country_code_iso3'] || address['country_code_iso3']
8689
)
8790
end
8891

@@ -106,25 +109,56 @@ def address_valid?(response)
106109
# @param rep_data [Hash] Original rep_data containing the address and other details.
107110
# @param api_response [Hash] The response from the address validation service.
108111
def update_rep_record(rep_data, api_response)
109-
record =
110-
Veteran::Service::Representative.find_by(representative_id: rep_data['id'])
111-
if record.nil?
112-
raise StandardError, 'Representative not found.'
113-
else
114-
address_attributes = rep_data['address_changed'] ? build_address_attributes(rep_data, api_response) : {}
115-
email_attributes = rep_data['email_changed'] ? build_email_attributes(rep_data) : {}
116-
phone_attributes = rep_data['phone_number_changed'] ? build_phone_attributes(rep_data) : {}
117-
record.update(merge_attributes(address_attributes, email_attributes, phone_attributes))
118-
end
112+
record = Veteran::Service::Representative.find_by(representative_id: rep_data['id'])
113+
raise StandardError, 'Representative not found.' if record.nil?
114+
115+
attributes, flags = build_rep_update_payload(rep_data, api_response)
116+
record.update(attributes)
117+
flags
119118
end
120119

121-
# Updates flags for the representative's records based on changes in address, email, or phone number.
122-
# @param rep_data [Hash] The representative data including the id and flags for changes.
123-
def update_flagged_records(rep_data)
124-
representative_id = rep_data['id']
125-
update_flags(representative_id, 'address') if rep_data['address_changed']
126-
update_flags(representative_id, 'email') if rep_data['email_changed']
127-
update_flags(representative_id, 'phone_number') if rep_data['phone_number_changed']
120+
# Build attributes hash and flags indicating which fields changed
121+
# @return [Array<Hash, Hash>] first element is attributes, second is flags
122+
def build_rep_update_payload(rep_data, api_response)
123+
flags = {}
124+
125+
address_attrs = if rep_data['address_changed'] && api_response.present?
126+
flags['address'] = true
127+
build_address_attributes(rep_data, api_response)
128+
else
129+
{}
130+
end
131+
132+
email_attrs = if rep_data['email_changed']
133+
flags['email'] = true
134+
build_email_attributes(rep_data)
135+
else
136+
{}
137+
end
138+
139+
phone_attrs = if rep_data['phone_number_changed']
140+
flags['phone_number'] = true
141+
build_phone_attributes(rep_data)
142+
else
143+
{}
144+
end
145+
146+
[merge_attributes(address_attrs, email_attrs, phone_attrs), flags]
147+
end
148+
149+
# Updates flags for the representative's records based on which fields were actually updated.
150+
# @param updated_flags [Hash] Hash with keys 'address', 'email', 'phone_number' set to true when updated.
151+
def update_flagged_records(updated_flags)
152+
return unless updated_flags.is_a?(Hash)
153+
154+
representative_id = updated_flags['representative_id']
155+
156+
# If representative_id is missing, we can't update flags
157+
return if representative_id.blank?
158+
159+
update_flags(representative_id, 'address') if updated_flags['address']
160+
update_flags(representative_id, 'email') if updated_flags['email']
161+
update_flags(representative_id, 'phone_number') if updated_flags['phone_number']
128162
end
129163

130164
# Updates the flags for a representative's contact data indicating a change.
@@ -160,51 +194,35 @@ def merge_attributes(address, email, phone)
160194

161195
# Builds the attributes for the record update from the address, geocode, and metadata.
162196
# @param address [Hash] Address details from the validation response.
163-
# @param geocode [Hash] Geocode details from the validation response.
164-
# @param meta [Hash] Metadata about the address from the validation response.
165197
# @return [Hash] The attributes to update the record with.
166-
def build_address(address, geocode, meta)
167-
{
168-
address_type: meta['address_type'],
169-
address_line1: address['address_line1'],
170-
address_line2: address['address_line2'],
171-
address_line3: address['address_line3'],
172-
city: address['city'],
173-
province: address['state_province']['name'],
174-
state_code: address['state_province']['code'],
175-
zip_code: address['zip_code5'],
176-
zip_suffix: address['zip_code4'],
177-
country_code_iso3: address['country']['iso3_code'],
178-
country_name: address['country']['name'],
179-
county_name: address.dig('county', 'name'),
180-
county_code: address.dig('county', 'county_fips_code'),
181-
lat: geocode['latitude'],
182-
long: geocode['longitude'],
183-
location: "POINT(#{geocode['longitude']} #{geocode['latitude']})"
184-
}
185-
end
186-
187198
def build_v3_address(address)
188199
{
189200
address_type: address['address_type'],
190201
address_line1: address['address_line1'],
191202
address_line2: address['address_line2'],
192203
address_line3: address['address_line3'],
193204
city: address['city_name'],
194-
province: address['state']['state_name'],
195-
state_code: address['state']['state_code'],
205+
province: address.dig('state', 'state_name'),
206+
state_code: address.dig('state', 'state_code'),
196207
zip_code: address['zip_code5'],
197208
zip_suffix: address['zip_code4'],
198-
country_code_iso3: address['country']['iso3_code'],
199-
country_name: address['country']['country_name'],
209+
country_code_iso3: address.dig('country', 'iso3_code'),
210+
country_name: address.dig('country', 'country_name'),
200211
county_name: address.dig('county', 'county_name'),
201212
county_code: address.dig('county', 'county_code'),
202-
lat: address['geocode']['latitude'],
203-
long: address['geocode']['longitude'],
204-
location: "POINT(#{address['geocode']['longitude']} #{address['geocode']['latitude']})"
213+
lat: address.dig('geocode', 'latitude'),
214+
long: address.dig('geocode', 'longitude'),
215+
location: build_point(address.dig('geocode', 'longitude'), address.dig('geocode', 'latitude'))
205216
}
206217
end
207218

219+
# Build a WKT point string if both coordinates are present
220+
def build_point(longitude, latitude)
221+
return nil if longitude.blank? || latitude.blank?
222+
223+
"POINT(#{longitude} #{latitude})"
224+
end
225+
208226
# Logs an error to Datadog and adds an error to the array that will be logged to slack.
209227
# @param error [Exception] The error string to be logged.
210228
def log_error(error, send_to_slack: false)
@@ -252,9 +270,7 @@ def modified_validation(address, retry_count)
252270
# @param response [Hash, Nil] the response from the address validation service
253271
# @return [Boolean]
254272
def retriable?(response)
255-
return true if response.blank?
256-
257-
!address_valid?(response) || lat_long_zero?(response)
273+
response.blank? || !address_valid?(response) || lat_long_zero?(response)
258274
end
259275

260276
# Retry address validation
@@ -297,21 +313,16 @@ def get_best_address_candidate(rep_address)
297313
raise
298314
end
299315

300-
return nil unless address_valid?(original_response)
301-
302-
# retry validation if we get zero as the coordinates - this should indicate some warning with validation that
303-
# is typically seen with addresses that mix street addresses with P.O. Boxes
304-
if lat_long_zero?(original_response)
316+
# If the original response is blank, invalid, or has zero coords, attempt retry logic which will
317+
# try modified address lines (including preprocessor-extracted PO Box) before giving up.
318+
if retriable?(original_response)
305319
retry_response = retry_validation(rep_address)
306320

307-
if retriable?(retry_response)
308-
nil
309-
else
310-
retry_response
311-
end
312-
else
313-
original_response
321+
return retriable?(retry_response) ? nil : retry_response
314322
end
323+
324+
# If we get here the original response was valid and not retriable (e.g. has non-zero coords)
325+
original_response
315326
end
316327

317328
# Determine if the backend exception represents a candidate address not found scenario
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe Veteran::AddressPreprocessor do
6+
describe '.clean' do
7+
it 'extracts room into address_line2' do
8+
addr = { 'address_line1' => '11000 Wilshire Blvd., Rm 509', 'address_line2' => nil }
9+
cleaned = described_class.clean(addr)
10+
expect(cleaned['address_line1']).to eq('11000 Wilshire Blvd.')
11+
expect(cleaned['address_line2']).to eq('Rm 509')
12+
end
13+
14+
it 'extracts PO Box and removes prefix' do
15+
addr = { 'address_line1' => '123 Main St Suite 5 PO Box 100', 'address_line2' => nil }
16+
cleaned = described_class.clean(addr)
17+
expect(cleaned['address_line1'].downcase).to include('po box 100')
18+
expect(cleaned['address_line2']).to be_nil
19+
end
20+
21+
it 'returns original when no changes' do
22+
addr = { 'address_line1' => '123 East Main St', 'address_line2' => 'Suite 2' }
23+
expect(described_class.clean(addr)).to eq(addr)
24+
end
25+
26+
it 'returns PO Box only when room and PO Box present' do
27+
addr = { 'address_line1' => 'PO Box 123, Rm 5', 'address_line2' => nil }
28+
cleaned = described_class.clean(addr)
29+
expect(cleaned['address_line1']).to eq('PO Box 123')
30+
expect(cleaned['address_line2']).to be_nil
31+
end
32+
33+
it 'returns nil address lines when only room is present' do
34+
addr = { 'address_line1' => 'Rm 5', 'address_line2' => nil }
35+
cleaned = described_class.clean(addr)
36+
expect(cleaned['address_line1']).to be_nil
37+
expect(cleaned['address_line2']).to be_nil
38+
end
39+
end
40+
end

0 commit comments

Comments
 (0)