diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb index 52b3412e0..699fc2128 100644 --- a/app/jobs/account/contacts_export_job.rb +++ b/app/jobs/account/contacts_export_job.rb @@ -32,6 +32,7 @@ class Account::ContactsExportJob < ApplicationJob def value_for_header(contact, header) return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN + return contact.canonical_country_code if header == 'country_code' contact.send(header) end diff --git a/app/models/concerns/contact_country_helpers.rb b/app/models/concerns/contact_country_helpers.rb new file mode 100644 index 000000000..329ceff6f --- /dev/null +++ b/app/models/concerns/contact_country_helpers.rb @@ -0,0 +1,26 @@ +module ContactCountryHelpers + extend ActiveSupport::Concern + + def canonical_country_code + attributes = additional_attributes || {} + + CountryCodeNormalizer.normalize(self[:country_code]) || + CountryCodeNormalizer.normalize(attributes['country_code']) || + CountryCodeNormalizer.normalize(attributes['country']) + end + + def canonical_country_name + CountryCodeNormalizer.name_for(canonical_country_code) + end + + def additional_attributes_with_canonical_country + attributes = additional_attributes.deep_dup || {} + code = canonical_country_code + return attributes if code.blank? + + attributes.merge( + 'country_code' => code, + 'country' => canonical_country_name || attributes['country'] + ) + end +end diff --git a/app/models/contact.rb b/app/models/contact.rb index 3badf478d..7c73ed5a2 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -44,6 +44,7 @@ class Contact < ApplicationRecord include Avatarable include AvailabilityStatusable + include ContactCountryHelpers include Labelable include LlmFormattable diff --git a/app/services/country_code_normalizer.rb b/app/services/country_code_normalizer.rb new file mode 100644 index 000000000..7e162c07a --- /dev/null +++ b/app/services/country_code_normalizer.rb @@ -0,0 +1,42 @@ +require 'tzinfo' + +class CountryCodeNormalizer + COUNTRY_ALIASES = { + 'united states of america' => 'US', + 'usa' => 'US', + 'uk' => 'GB', + 'united kingdom' => 'GB' + }.freeze + + class << self + def normalize(value) + return if value.blank? + + value = value.to_s.strip + code = value.upcase + return code if country_by_code.key?(code) + + country_by_name[normalized_name(value)] || COUNTRY_ALIASES[normalized_name(value)] + end + + def name_for(code) + country_by_code[normalize(code)]&.name + end + + private + + def country_by_code + @country_by_code ||= TZInfo::Country.all_codes.index_with { |code| TZInfo::Country.get(code) } + end + + def country_by_name + @country_by_name ||= country_by_code.each_with_object({}) do |(code, country), result| + result[normalized_name(country.name)] = code + end + end + + def normalized_name(value) + value.to_s.strip.downcase + end + end +end diff --git a/app/services/llm_formatter/contact_llm_formatter.rb b/app/services/llm_formatter/contact_llm_formatter.rb index 586f8743b..d17899f25 100644 --- a/app/services/llm_formatter/contact_llm_formatter.rb +++ b/app/services/llm_formatter/contact_llm_formatter.rb @@ -26,7 +26,7 @@ class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter attributes << "Email: #{@record.email}" attributes << "Phone: #{@record.phone_number}" attributes << "Location: #{@record.location}" - attributes << "Country Code: #{@record.country_code}" + attributes << "Country Code: #{@record.canonical_country_code}" @record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute| attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}" end diff --git a/app/views/api/v1/accounts/search/_contact.json.jbuilder b/app/views/api/v1/accounts/search/_contact.json.jbuilder index 0d9578725..ca7f5544f 100644 --- a/app/views/api/v1/accounts/search/_contact.json.jbuilder +++ b/app/views/api/v1/accounts/search/_contact.json.jbuilder @@ -3,5 +3,5 @@ json.id contact.id json.name contact.name json.phone_number contact.phone_number json.identifier contact.identifier -json.additional_attributes contact.additional_attributes +json.additional_attributes contact.additional_attributes_with_canonical_country json.last_activity_at contact.last_activity_at&.to_i diff --git a/app/views/api/v1/models/_contact.json.jbuilder b/app/views/api/v1/models/_contact.json.jbuilder index 4e998ddf1..18fddea33 100644 --- a/app/views/api/v1/models/_contact.json.jbuilder +++ b/app/views/api/v1/models/_contact.json.jbuilder @@ -1,4 +1,4 @@ -json.additional_attributes resource.additional_attributes +json.additional_attributes resource.additional_attributes_with_canonical_country json.availability_status resource.availability_status json.email resource.email json.id resource.id diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 2255a1215..6f663c8b3 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -54,6 +54,22 @@ RSpec.describe 'Contacts API', type: :request do expect(contact_inboxes_source_ids).to include(contact_inbox.source_id) end + it 'returns canonical country attributes for contact display' do + contact.update!(additional_attributes: { country: 'United States' }) + + get "/api/v1/accounts/#{account.id}/contacts", + headers: admin.create_new_auth_token, + as: :json + + response_body = response.parsed_body + response_contact = response_body['payload'].find { |item| item['id'] == contact.id } + + expect(response_contact['additional_attributes']).to include( + 'country_code' => 'US', + 'country' => 'United States' + ) + end + it 'returns all contacts without contact inboxes' do get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false", headers: admin.create_new_auth_token, diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb index 0714fb55b..784e55047 100644 --- a/spec/jobs/account/contacts_export_job_spec.rb +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -99,6 +99,19 @@ RSpec.describe Account::ContactsExportJob do expect(row['labels']).to eq('vip') end + it 'exports canonical country codes from legacy country data' do + legacy_country_contact = create(:contact, account: account, email: 'legacy-country@example.com', + additional_attributes: { country: 'United States' }) + + described_class.perform_now(account.id, user.id, %w[email country_code], {}) + + csv_content = account.contacts_export.download.force_encoding('UTF-8').delete_prefix("\xEF\xBB\xBF") + csv_data = CSV.parse(csv_content, headers: true) + row = csv_data.find { |record| record['email'] == legacy_country_contact.email } + + expect(row['country_code']).to eq('US') + end + it 'bulk loads labels while exporting contacts' do create(:label, account: account, title: 'vip') create(:label, account: account, title: 'support') diff --git a/spec/models/contact_spec.rb b/spec/models/contact_spec.rb index 159454eec..cdcb32165 100644 --- a/spec/models/contact_spec.rb +++ b/spec/models/contact_spec.rb @@ -93,6 +93,42 @@ RSpec.describe Contact do end end + describe '#canonical_country_code' do + it 'prefers the contact country_code column when it has a valid country code' do + contact = create(:contact, additional_attributes: { country_code: 'IN', country: 'India' }) + contact[:country_code] = 'US' + + expect(contact.canonical_country_code).to eq('US') + expect(contact.canonical_country_name).to eq('United States') + end + + it 'falls back to additional_attributes country_code' do + contact = create(:contact, additional_attributes: { country_code: 'in' }) + + expect(contact.canonical_country_code).to eq('IN') + expect(contact.canonical_country_name).to eq('India') + end + + it 'normalizes legacy country names from additional_attributes country' do + contact = create(:contact, additional_attributes: { country: 'United States' }) + + expect(contact.canonical_country_code).to eq('US') + expect(contact.canonical_country_name).to eq('United States') + end + end + + describe '#additional_attributes_with_canonical_country' do + it 'returns additional attributes with normalized country values without persisting them' do + contact = create(:contact, additional_attributes: { country: 'United States' }) + + expect(contact.additional_attributes_with_canonical_country).to include( + 'country_code' => 'US', + 'country' => 'United States' + ) + expect(contact.reload.additional_attributes).not_to include('country_code') + end + end + context 'when a contact is created' do it 'has contact type "visitor" by default' do contact = create(:contact) diff --git a/spec/services/llm_formatter/contact_llm_formatter_spec.rb b/spec/services/llm_formatter/contact_llm_formatter_spec.rb index bf3345b98..d4775b8bc 100644 --- a/spec/services/llm_formatter/contact_llm_formatter_spec.rb +++ b/spec/services/llm_formatter/contact_llm_formatter_spec.rb @@ -48,6 +48,16 @@ RSpec.describe LlmFormatter::ContactLlmFormatter do end end + context 'when contact has legacy country data' do + before do + contact.update!(additional_attributes: { country: 'United States' }) + end + + it 'uses the canonical country code' do + expect(formatter.format).to include('Country Code: US') + end + end + context 'when contact has custom attributes' do let!(:custom_attribute) do create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company')