feat: Add labels to contact import/export (#13313)

Adds label support to contact import and export so teams can carry
approved contact labels through CSV workflows. Imports accept a `labels`
column with labels that already exist in the account; multiple labels
should be entered as a quoted comma-separated CSV value, for example
`"customer,vip"`.

Imports are additive: they add labels to contacts and do not remove
labels already on a contact. Removing a label from the CSV row or
leaving the `labels` cell blank will not clear existing contact labels.
To remove a label, edit the contact directly.

## Closes

- Closes #8535

## How to test

1. Create a few contact labels in the account, such as `customer`,
`vip`, and `lead`.
2. Go to Contacts -> Import contacts and download the sample CSV.
3. Import contacts with a `labels` column. Use a single label like
`lead`, or quote multiple labels like `"customer,vip"`.
4. Confirm imported contacts are created with the expected labels.
5. Re-import an existing contact with a new label and confirm the new
label is added without removing existing labels.
6. Try a row with an unknown label, such as `"vip,unknown_label"`, and
confirm only that row is rejected in the failed records CSV while the
other valid rows are imported.
7. Export contacts and confirm the CSV includes a `labels` column with
comma-separated approved labels.

## What changed

- Contact exports include approved `labels` in the default CSV columns.
This adds a new default export column for CSV consumers.
- Contact imports parse `labels` as comma-separated values inside the
CSV cell.
- Imported labels are validated against labels that already exist in the
account.
- Rows with unknown labels are rejected with an `Unknown labels: ...`
error; valid rows in the same import continue to process.
- Imported labels are additive and do not remove existing contact
labels.
- Label application during import does not dispatch an additional
per-contact update event.
- The sample CSV includes an import-safe `labels` column. The modal
keeps the existing generic CSV import copy.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Ahmed Alwahib
2026-05-06 18:46:36 +05:30
committed by GitHub
co-authored by Sojan Jose
parent d7d1e4113c
commit d43a87c9dc
5 changed files with 302 additions and 38 deletions
+41 -4
View File
@@ -1,6 +1,9 @@
class Account::ContactsExportJob < ApplicationJob
queue_as :low
LABELS_COLUMN = 'labels'.freeze
LABELS_DELIMITER = ','.freeze
def perform(account_id, user_id, column_names, params)
@account = Account.find(account_id)
@params = params
@@ -14,16 +17,45 @@ class Account::ContactsExportJob < ApplicationJob
private
def generate_csv(headers)
contacts_to_export = contacts.to_a
preload_contact_labels(contacts_to_export) if headers.include?(LABELS_COLUMN)
csv_data = CSV.generate do |csv|
csv << headers
contacts.each do |contact|
csv << headers.map { |header| contact.send(header) }
contacts_to_export.each do |contact|
csv << headers.map { |header| value_for_header(contact, header) }
end
end
attach_export_file(csv_data)
end
def value_for_header(contact, header)
return contact_labels_by_id.fetch(contact.id, []).join(LABELS_DELIMITER) if header == LABELS_COLUMN
contact.send(header)
end
def approved_labels
@approved_labels ||= @account.labels.pluck(:title)
end
def preload_contact_labels(contacts_to_export)
contact_ids = contacts_to_export.map(&:id)
return if contact_ids.blank?
ActsAsTaggableOn::Tagging
.joins(:tag)
.where(context: LABELS_COLUMN, taggable_type: 'Contact', taggable_id: contact_ids)
.where(tags: { name: approved_labels })
.pluck(:taggable_id, 'tags.name')
.each { |contact_id, label| contact_labels_by_id[contact_id] << label }
end
def contact_labels_by_id
@contact_labels_by_id ||= Hash.new { |hash, contact_id| hash[contact_id] = [] }
end
def contacts
if @params.present? && @params[:payload].present? && @params[:payload].any?
result = ::Contacts::FilterService.new(@account, @account_user, @params).perform
@@ -36,7 +68,12 @@ class Account::ContactsExportJob < ApplicationJob
end
def valid_headers(column_names)
(column_names.presence || default_columns) & Contact.column_names
requested_headers = column_names.presence || default_columns
# Keep requested header order while allowing the virtual labels column.
requested_headers.select do |header|
header == LABELS_COLUMN || Contact.column_names.include?(header)
end.uniq
end
def attach_export_file(csv_data)
@@ -65,6 +102,6 @@ class Account::ContactsExportJob < ApplicationJob
end
def default_columns
%w[id name email phone_number]
%w[id name email phone_number labels]
end
end
+102 -7
View File
@@ -5,6 +5,10 @@ class DataImportJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
LABELS_DELIMITER = ','.freeze
LABELS_CONTEXT = 'labels'.freeze
CONTACT_TAGGABLE_TYPE = 'Contact'.freeze
def perform(data_import)
@data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account)
@@ -33,26 +37,117 @@ class DataImportJob < ApplicationJob
with_import_file do |file|
csv_reader(file).each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
build_contact_from_row(row, contacts, rejected_contacts)
end
end
[contacts, rejected_contacts]
end
def build_contact_from_row(row, contacts, rejected_contacts)
row_hash = row.to_h.with_indifferent_access
labels = extract_labels(row_hash)
invalid_labels = labels.map(&:downcase) - approved_labels
if invalid_labels.present?
append_label_error(row, invalid_labels, rejected_contacts)
return
end
current_contact = @contact_manager.build_contact(row_hash.except(:labels))
if current_contact.valid?
contacts << { contact: current_contact, labels: labels }
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
def extract_labels(row_hash)
row_hash[:labels].to_s.split(LABELS_DELIMITER).map(&:strip).reject(&:blank?)
end
def append_rejected_contact(row, contact, rejected_contacts)
row['errors'] = contact.errors.full_messages.join(', ')
rejected_contacts << row
end
def import_contacts(contacts)
def import_contacts(contacts_with_labels)
contacts = contacts_with_labels.pluck(:contact)
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
apply_labels_to_contacts(contacts_with_labels)
end
def apply_labels_to_contacts(contacts_with_labels)
taggings = taggings_for_contacts(contacts_with_labels)
return if taggings.blank?
ActsAsTaggableOn::Tagging.import(%i[tag_id taggable_type taggable_id context created_at],
taggings, on_duplicate_key_ignore: true, validate: false, batch_size: 1000)
end
def taggings_for_contacts(contacts_with_labels)
tag_lookup = tags_by_label_name(contacts_with_labels)
taggings = contacts_with_labels.flat_map do |item|
contact = contact_for_label_import(item[:contact])
labels = item[:labels].map(&:downcase).uniq
next [] if contact&.id.blank?
labels.map do |label|
[tag_lookup[label].id, CONTACT_TAGGABLE_TYPE, contact.id, LABELS_CONTEXT]
end
end.uniq
reject_existing_taggings(taggings).map { |tagging| tagging + [Time.zone.now] }
end
def reject_existing_taggings(taggings)
tag_ids = taggings.map { |tag_id, _taggable_type, _taggable_id, _context| tag_id }
taggable_ids = taggings.map { |_tag_id, _taggable_type, taggable_id, _context| taggable_id }
existing_taggings = ActsAsTaggableOn::Tagging
.where(context: LABELS_CONTEXT, taggable_type: CONTACT_TAGGABLE_TYPE,
taggable_id: taggable_ids, tag_id: tag_ids)
.pluck(:tag_id, :taggable_id)
.index_with(true)
taggings.reject do |tag_id, _taggable_type, taggable_id, _context|
existing_taggings[[tag_id, taggable_id]]
end
end
def contact_for_label_import(contact)
return contact if contact.id.present?
key = contact_identity_key(contact)
return if key.blank?
imported_contact(contact)
end
def contact_identity_key(contact)
contact.identifier.presence || contact.email.presence || contact.phone_number.presence
end
def imported_contact(contact)
return @data_import.account.contacts.find_by(identifier: contact.identifier) if contact.identifier.present?
return @data_import.account.contacts.from_email(contact.email) if contact.email.present?
@data_import.account.contacts.find_by(phone_number: contact.phone_number) if contact.phone_number.present?
end
def tags_by_label_name(contacts_with_labels)
labels = contacts_with_labels.flat_map { |item| item[:labels] }.map(&:downcase).uniq
ActsAsTaggableOn::Tag.find_or_create_all_with_like_by_name(labels).index_by { |tag| tag.name.downcase }
end
def approved_labels
@approved_labels ||= @data_import.account.labels.pluck(:title)
end
def append_label_error(row, labels, rejected_contacts)
row['errors'] = "Unknown labels: #{labels.join(', ')}"
rejected_contacts << row
end
def update_data_import_status(processed_records, rejected_records)
+26 -26
View File
@@ -1,26 +1,26 @@
id,name,email,identifier,phone_number,ip_address,company_name,custom_attribute_1,custom_attribute_2
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Acme Inc,Random-value0,Random-value0
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Acme Inc,Random-value1,Random-value1
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Acme Inc,Random-value2,Random-value2
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Acme Inc,Random-value3,Random-value3
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Acme Inc,Random-value4,Random-value4
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Acme Inc,Random-value5,Random-value5
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Acme Inc,Random-value6,Random-value6
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Acme Inc,Random-value7,Random-value7
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Acme Inc,Random-value8,Random-value8
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Acme Inc,Random-value9,Random-value9
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Acme Inc,Random-value10,Random-value10
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Acme Inc,Random-value11,Random-value11
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Acme Inc,Random-value12,Random-value12
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Acme Inc,Random-value13,Random-value13
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Acme Inc,Random-value14,Random-value14
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Acme Inc,Random-value15,Random-value15
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Acme Inc,Random-value16,Random-value16
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Acme Inc,Random-value17,Random-value17
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Acme Inc,Random-value18,Random-value18
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Acme Inc,Random-value19,Random-value19
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Acme Inc,Random-value20,Random-value20
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Acme Inc,Random-value21,Random-value21
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Acme Inc,Random-value22,Random-value22
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Acme Inc,Random-value23,Random-value23
id,name,email,identifier,phone_number,labels,ip_address,company_name,custom_attribute_1,custom_attribute_2
1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,,70.61.11.201,Acme Inc,Random-value-1,Random-value-1
2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,,168.186.4.241,Acme Inc,Random-value0,Random-value0
3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,,73.44.41.59,Acme Inc,Random-value1,Random-value1
4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,,115.249.27.155,Acme Inc,Random-value2,Random-value2
5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,,219.181.212.8,Acme Inc,Random-value3,Random-value3
6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,,231.210.115.166,Acme Inc,Random-value4,Random-value4
7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,,160.189.41.11,Acme Inc,Random-value5,Random-value5
8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,,53.94.18.201,Acme Inc,Random-value6,Random-value6
9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,,18.87.247.249,Acme Inc,Random-value7,Random-value7
10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,,25.191.96.124,Acme Inc,Random-value8,Random-value8
11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,,11.211.174.93,Acme Inc,Random-value9,Random-value9
12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,,47.26.205.153,Acme Inc,Random-value10,Random-value10
13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,,157.196.34.166,Acme Inc,Random-value11,Random-value11
14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,,109.231.152.148,Acme Inc,Random-value12,Random-value12
15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,,20.43.146.179,Acme Inc,Random-value13,Random-value13
16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,,179.76.226.171,Acme Inc,Random-value14,Random-value14
17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,,92.243.194.160,Acme Inc,Random-value15,Random-value15
18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,,25.22.86.101,Acme Inc,Random-value16,Random-value16
19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,,150.249.116.118,Acme Inc,Random-value17,Random-value17
20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,,237.167.197.197,Acme Inc,Random-value18,Random-value18
21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,,88.102.64.113,Acme Inc,Random-value19,Random-value19
22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,,141.248.89.29,Acme Inc,Random-value20,Random-value20
23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,,118.123.138.115,Acme Inc,Random-value21,Random-value21
24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,,157.45.102.235,Acme Inc,Random-value22,Random-value22
25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,,170.73.198.47,Acme Inc,Random-value23,Random-value23
1 id name email identifier phone_number labels ip_address company_name custom_attribute_1 custom_attribute_2
2 1 Clarice Uzzell cuzzell0@mozilla.org bb4e11cd-0f23-49da-a123-dcc1fec6852c +498963648018 70.61.11.201 Acme Inc Random-value-1 Random-value-1
3 2 Marieann Creegan mcreegan1@cornell.edu e60bab4c-9fbb-47eb-8f75-42025b789c47 +15417543010 168.186.4.241 Acme Inc Random-value0 Random-value0
4 3 Nancey Windibank nwindibank2@bluehost.com f793e813-4210-4bf3-a812-711418de25d2 +15417543011 73.44.41.59 Acme Inc Random-value1 Random-value1
5 4 Sibel Stennine sstennine3@yellowbook.com d6e35a2d-d093-4437-a577-7df76316b937 +15417543011 115.249.27.155 Acme Inc Random-value2 Random-value2
6 5 Tina O'Lunney tolunney4@si.edu 3540d40a-5567-4f28-af98-5583a7ddbc56 +15417543011 219.181.212.8 Acme Inc Random-value3 Random-value3
7 6 Quinn Neve qneve5@army.mil ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5 +15417543011 231.210.115.166 Acme Inc Random-value4 Random-value4
8 7 Karylin Gaunson kgaunson6@tripod.com d24cac79-c81b-4b84-a33e-0441b7c6a981 +15417543011 160.189.41.11 Acme Inc Random-value5 Random-value5
9 8 Jamison Shenton jshenton7@upenn.edu 29a7a8c0-c7f7-4af9-852f-761b1a784a7a +15417543011 53.94.18.201 Acme Inc Random-value6 Random-value6
10 9 Gavan Threlfall gthrelfall8@spotify.com 847d4943-ddb5-47cc-8008-ed5092c675c5 +15417543011 18.87.247.249 Acme Inc Random-value7 Random-value7
11 10 Katina Hemmingway khemmingway9@ameblo.jp 8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048 +15417543011 25.191.96.124 Acme Inc Random-value8 Random-value8
12 11 Jillian Deinhard jdeinharda@canalblog.com bd952787-1b05-411f-9975-b916ec0950cc +15417543011 11.211.174.93 Acme Inc Random-value9 Random-value9
13 12 Blake Finden bfindenb@wsj.com 12c95613-e49d-4fa2-86fb-deabb6ebe600 +15417543011 47.26.205.153 Acme Inc Random-value10 Random-value10
14 13 Liane Maxworthy lmaxworthyc@un.org 36b68e4c-40d6-4e09-bf59-7db3b27b18f0 +15417543011 157.196.34.166 Acme Inc Random-value11 Random-value11
15 14 Martynne Ledley mledleyd@sourceforge.net 1856bceb-cb36-415c-8ffc-0527f3f750d8 +15417543011 109.231.152.148 Acme Inc Random-value12 Random-value12
16 15 Katharina Ruffli krufflie@huffingtonpost.com 604de5c9-b154-4279-8978-41fb71f0f773 +15417543011 20.43.146.179 Acme Inc Random-value13 Random-value13
17 16 Tucker Simmance tsimmancef@bbc.co.uk 0a8fc3a7-4986-4a51-a503-6c7f974c90ad +15417543011 179.76.226.171 Acme Inc Random-value14 Random-value14
18 17 Wenona Martinson wmartinsong@census.gov 0e5ea6e3-6824-4e78-a6f5-672847eafa17 +15417543011 92.243.194.160 Acme Inc Random-value15 Random-value15
19 18 Gretna Vedyasov gvedyasovh@lycos.com 6becf55b-a7b5-48f6-8788-b89cae85b066 +15417543011 25.22.86.101 Acme Inc Random-value16 Random-value16
20 19 Lurline Abdon labdoni@archive.org afa9429f-9034-4b06-9efa-980e01906ebf +15417543011 150.249.116.118 Acme Inc Random-value17 Random-value17
21 20 Fiann Norcliff fnorcliffj@istockphoto.com 59f72dec-14ba-4d6e-b17c-0d962e69ffac +15417543011 237.167.197.197 Acme Inc Random-value18 Random-value18
22 21 Zed Linn zlinnk@phoca.cz 95f7bc56-be92-4c9c-ad58-eff3e63c7bea +15417543011 88.102.64.113 Acme Inc Random-value19 Random-value19
23 22 Averyl Simyson asimysonl@livejournal.com bde1fe59-c9bd-440c-bb39-79fe61dac1d1 +15417543011 141.248.89.29 Acme Inc Random-value20 Random-value20
24 23 Camella Blackadder cblackadderm@nifty.com 0c981752-5857-487c-b9b5-5d0253df740a +15417543011 118.123.138.115 Acme Inc Random-value21 Random-value21
25 24 Aurie Spatig aspatign@printfriendly.com 4cf22bfb-2c3f-41d1-9993-6e3758e457ba +15417543011 157.45.102.235 Acme Inc Random-value22 Random-value22
26 25 Adrienne Bellard abellardo@cnn.com f10f9b8d-38ac-4e17-8a7d-d2e6a055f944 +15417543011 170.73.198.47 Acme Inc Random-value23 Random-value23
+37 -1
View File
@@ -5,7 +5,6 @@ RSpec.describe Account::ContactsExportJob do
let(:account) { create(:account) }
let(:user) { create(:user, account: account, email: 'account-user-test@test.com') }
let(:label) { create(:label, title: 'spec-billing', maccount: account) }
let(:email_filter) do
{
@@ -85,6 +84,43 @@ RSpec.describe Account::ContactsExportJob do
expect(phone_numbers).to include('+910808080818', '+910808080808')
end
it 'exports labels when requested through column names' do
contact_with_labels = account.contacts.first
create(:label, account: account, title: 'vip')
contact_with_labels.add_labels(%w[vip])
described_class.perform_now(account.id, user.id, %w[id email labels], {})
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 { |r| r['email'] == contact_with_labels.email }
expect(csv_data.headers).to eq(%w[id email labels])
expect(row['labels']).to eq('vip')
end
it 'bulk loads labels while exporting contacts' do
create(:label, account: account, title: 'vip')
create(:label, account: account, title: 'support')
account.contacts.find_each { |contact| contact.add_labels(%w[vip support]) }
account.contacts.first.add_labels('legacy_tag')
taggings_queries = []
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, _started, _finished, _unique_id, payload|
taggings_queries << payload[:sql] if payload[:sql].include?('FROM "taggings"')
end
described_class.perform_now(account.id, user.id, [], {})
csv_data = CSV.parse(account.contacts_export.download, headers: true)
row = csv_data.find { |r| r['email'] == account.contacts.first.email }
expect(csv_data.headers).to include('labels')
expect(row['labels'].split(described_class::LABELS_DELIMITER)).to match_array(%w[vip support])
expect(taggings_queries.size).to eq(1)
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
it 'prepends UTF-8 BOM to the exported CSV for spreadsheet compatibility' do
described_class.perform_now(account.id, user.id, [], {})
+96
View File
@@ -187,5 +187,101 @@ RSpec.describe DataImportJob do
.to change { data_import.reload.status }.from('pending').to('failed')
end
end
context 'when the data contains labels column' do
let(:data_with_labels) do
[
%w[id name email phone_number labels],
['1', 'John Doe', 'john@example.com', '+918080808080', ' Customer , VIP , vip '],
['2', 'Jane Smith', 'jane@example.com', '+918080808081', 'lead'],
['3', 'Bob Wilson', 'bob@example.com', '+918080808082', '']
]
end
let(:labels_data_import) { create(:data_import, import_file: generate_csv_file(data_with_labels)) }
before do
%w[customer vip lead].each do |title|
create(:label, account: labels_data_import.account, title: title)
end
end
it 'imports contacts with labels from CSV' do
described_class.perform_now(labels_data_import)
john = Contact.from_email('john@example.com')
expect(john).to be_present
expect(john.label_list).to contain_exactly('customer', 'vip')
jane = Contact.from_email('jane@example.com')
expect(jane).to be_present
expect(jane.label_list).to contain_exactly('lead')
bob = Contact.from_email('bob@example.com')
expect(bob).to be_present
expect(bob.label_list).to be_empty
end
it 'dispatches only the contact update event when importing labels for an existing contact' do
existing_contact = create(:contact, account: labels_data_import.account, email: 'existing-labeled@example.com', name: 'Old Name')
existing_contact.add_labels('customer')
data_with_existing_contact = [
%w[id name email phone_number labels],
['1', 'Updated Name', existing_contact.email, '+918080808090', 'lead'],
['2', 'New Labeled Contact', 'new-labeled@example.com', '+918080808091', 'customer']
]
existing_contact_import = create(:data_import, account: labels_data_import.account,
import_file: generate_csv_file(data_with_existing_contact))
allow(Rails.configuration.dispatcher).to receive(:dispatch)
described_class.perform_now(existing_contact_import)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
Events::Types::CONTACT_UPDATED,
anything,
hash_including(contact: have_attributes(id: existing_contact.id))
).once
expect(existing_contact.reload.label_list).to contain_exactly('customer', 'lead')
expect(labels_data_import.account.contacts.from_email('new-labeled@example.com').label_list).to contain_exactly('customer')
end
it 'merges labels for duplicate contact rows without duplicate taggings' do
data_with_duplicate_contact = [
%w[id name email phone_number labels],
['1', 'Duplicate User', 'duplicate-labeled@example.com', '+918080808092', 'lead'],
['2', 'Duplicate User', 'duplicate-labeled@example.com', '+918080808092', 'customer,lead']
]
duplicate_contact_import = create(:data_import, account: labels_data_import.account,
import_file: generate_csv_file(data_with_duplicate_contact))
described_class.perform_now(duplicate_contact_import)
contact = labels_data_import.account.contacts.from_email('duplicate-labeled@example.com')
lead = ActsAsTaggableOn::Tag.find_by(name: 'lead')
expect(contact.label_list).to contain_exactly('customer', 'lead')
expect(ActsAsTaggableOn::Tagging.where(tag_id: lead.id, taggable: contact, context: 'labels').count).to eq(1)
end
it 'rejects rows with labels that do not exist in the account before updating contacts' do
existing_contact = create(:contact,
account: labels_data_import.account,
email: 'existing@example.com',
phone_number: '+918080808085',
name: 'Existing Name')
data_with_unknown_labels = [
%w[id name email phone_number labels],
['1', 'Updated Name', existing_contact.email, '+918080808086', 'vip,unknown_label']
]
unknown_label_import = create(:data_import, account: labels_data_import.account,
import_file: generate_csv_file(data_with_unknown_labels))
described_class.perform_now(unknown_label_import)
expect(existing_contact.reload.name).to eq('Existing Name')
expect(existing_contact.phone_number).to eq('+918080808085')
expect(unknown_label_import.reload.failed_records).to be_attached
expect(unknown_label_import.failed_records.download).to include('Unknown labels: unknown_label')
end
end
end
end