feat: Add bulk imports API for contacts (#1724)
This commit is contained in:
parent
7748e0c56e
commit
c61edff189
20 changed files with 278 additions and 14 deletions
2
Gemfile
2
Gemfile
|
@ -44,6 +44,8 @@ gem 'pg'
|
|||
gem 'redis'
|
||||
gem 'redis-namespace'
|
||||
gem 'redis-rack-cache'
|
||||
# super fast record imports in bulk
|
||||
gem 'activerecord-import'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails'
|
||||
|
|
|
@ -62,6 +62,8 @@ GEM
|
|||
activerecord (6.0.3.4)
|
||||
activemodel (= 6.0.3.4)
|
||||
activesupport (= 6.0.3.4)
|
||||
activerecord-import (1.0.7)
|
||||
activerecord (>= 3.2)
|
||||
activestorage (6.0.3.4)
|
||||
actionpack (= 6.0.3.4)
|
||||
activejob (= 6.0.3.4)
|
||||
|
@ -582,6 +584,7 @@ PLATFORMS
|
|||
|
||||
DEPENDENCIES
|
||||
action-cable-testing
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate
|
||||
annotate
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_contact
|
||||
before_action :ensure_inbox, only: [:create]
|
||||
before_action :validate_channel_type
|
||||
|
||||
def create
|
||||
source_id = params[:source_id] || SecureRandom.uuid
|
||||
@contact_inbox = ContactInbox.create(contact: @contact, inbox: @inbox, source_id: source_id)
|
||||
@contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_channel_type
|
||||
return if @inbox.channel_type == 'Channel::Api'
|
||||
|
||||
render json: { error: 'Contact Inbox creation is only allowed in API inboxes' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def ensure_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
|
|
|
@ -22,6 +22,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
@contacts = fetch_contact_last_seen_at(contacts)
|
||||
end
|
||||
|
||||
def import
|
||||
ActiveRecord::Base.transaction do
|
||||
import = Current.account.data_imports.create!(data_type: 'contacts')
|
||||
import.import_file.attach(params[:import_file])
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
|
||||
# returns online contacts
|
||||
def active
|
||||
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
|
|
46
app/jobs/data_import_job.rb
Normal file
46
app/jobs/data_import_job.rb
Normal file
|
@ -0,0 +1,46 @@
|
|||
# TODO: logic is written tailored to contact import since its the only import available
|
||||
# let's break this logic and clean this up in future
|
||||
|
||||
class DataImportJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(data_import)
|
||||
contacts = []
|
||||
data_import.update!(status: :processing)
|
||||
csv = CSV.parse(data_import.import_file.download, headers: true)
|
||||
csv.each { |row| contacts << build_contact(row.to_h.with_indifferent_access, data_import.account) }
|
||||
result = Contact.import contacts, on_duplicate_key_update: :all, batch_size: 1000
|
||||
data_import.update!(status: :completed, processed_records: csv.length - result.failed_instances.length, total_records: csv.length)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_contact(params, account)
|
||||
# TODO: rather than doing the find or initialize individually lets fetch objects in bulk and update them in memory
|
||||
contact = init_contact(params, account)
|
||||
|
||||
contact.name = params[:name] if params[:name].present?
|
||||
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name)))
|
||||
|
||||
# since callbacks aren't triggered lets ensure a pubsub token
|
||||
contact.pubsub_token ||= SecureRandom.base58(24)
|
||||
contact
|
||||
end
|
||||
|
||||
def get_identified_contacts(params, account)
|
||||
identifier_contact = account.contacts.find_by(identifier: params[:identifier]) if params[:identifier]
|
||||
email_contact = account.contacts.find_by(email: params[:email]) if params[:email]
|
||||
[identifier_contact, email_contact]
|
||||
end
|
||||
|
||||
def init_contact(params, account)
|
||||
identifier_contact, email_contact = get_identified_contacts(params, account)
|
||||
|
||||
# intiating the new contact / contact attributes only by ensuring the identifier or email duplication errors won't occur
|
||||
contact = identifier_contact
|
||||
contact&.email = params[:email] if params[:email].present? && email_contact.blank?
|
||||
contact ||= email_contact
|
||||
contact ||= account.contacts.new(params.slice(:email, :identifier))
|
||||
contact
|
||||
end
|
||||
end
|
|
@ -34,6 +34,7 @@ class Account < ApplicationRecord
|
|||
|
||||
has_many :account_users, dependent: :destroy
|
||||
has_many :agent_bot_inboxes, dependent: :destroy
|
||||
has_many :data_imports, dependent: :destroy
|
||||
has_many :users, through: :account_users
|
||||
has_many :inboxes, dependent: :destroy
|
||||
has_many :conversations, dependent: :destroy
|
||||
|
|
|
@ -27,6 +27,7 @@ class ContactInbox < ApplicationRecord
|
|||
validates :inbox_id, presence: true
|
||||
validates :contact_id, presence: true
|
||||
validates :source_id, presence: true
|
||||
validate :valid_source_id_format?
|
||||
|
||||
belongs_to :contact
|
||||
belongs_to :inbox
|
||||
|
@ -47,4 +48,24 @@ class ContactInbox < ApplicationRecord
|
|||
def current_conversation
|
||||
conversations.last
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_twilio_source_id
|
||||
# https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164
|
||||
if inbox.channel.medium == 'sms' && !/^\+[1-9]\d{1,14}$/.match?(source_id)
|
||||
errors.add(:source_id, 'invalid source id for twilio sms inbox. valid Regex /^\+[1-9]\d{1,14}$/')
|
||||
elsif inbox.channel.medium == 'whatsapp' && !/^whatsapp:\+[1-9]\d{1,14}$/.match?(source_id)
|
||||
errors.add(:source_id, 'invalid source id for twilio whatsapp inbox. valid Regex /^whatsapp:\+[1-9]\d{1,14}$/')
|
||||
end
|
||||
end
|
||||
|
||||
def validate_email_source_id
|
||||
errors.add(:source_id, "invalid source id for Email inbox. valid Regex #{Device.email_regexp}") unless Devise.email_regexp.match?(source_id)
|
||||
end
|
||||
|
||||
def valid_source_id_format?
|
||||
validate_twilio_source_id if inbox.channel_type == 'Channel::TwilioSms'
|
||||
validate_email_source_id if inbox.channel_type == 'Channel::Email'
|
||||
end
|
||||
end
|
||||
|
|
37
app/models/data_import.rb
Normal file
37
app/models/data_import.rb
Normal file
|
@ -0,0 +1,37 @@
|
|||
# == Schema Information
|
||||
#
|
||||
# Table name: data_imports
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# data_type :string not null
|
||||
# processed_records :integer
|
||||
# processing_errors :text
|
||||
# status :integer default("pending"), not null
|
||||
# total_records :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_data_imports_on_account_id (account_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class DataImport < ApplicationRecord
|
||||
belongs_to :account
|
||||
validates :data_type, inclusion: { in: ['contacts'], message: '%<value>s is an invalid data type' }
|
||||
enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
|
||||
|
||||
has_one_attached :import_file
|
||||
|
||||
after_create_commit :process_data_import
|
||||
|
||||
private
|
||||
|
||||
def process_data_import
|
||||
DataImportJob.perform_later(self)
|
||||
end
|
||||
end
|
|
@ -7,6 +7,10 @@ class ContactPolicy < ApplicationPolicy
|
|||
true
|
||||
end
|
||||
|
||||
def import?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def search?
|
||||
true
|
||||
end
|
||||
|
|
|
@ -67,6 +67,7 @@ Rails.application.routes.draw do
|
|||
collection do
|
||||
get :active
|
||||
get :search
|
||||
post :import
|
||||
end
|
||||
scope module: :contacts do
|
||||
resources :conversations, only: [:index]
|
||||
|
|
14
db/migrate/20210201150037_create_data_imports.rb
Normal file
14
db/migrate/20210201150037_create_data_imports.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class CreateDataImports < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
create_table :data_imports do |t|
|
||||
t.references :account, null: false, foreign_key: true
|
||||
t.string :data_type, null: false
|
||||
t.integer :status, null: false, default: 0
|
||||
t.text :processing_errors
|
||||
t.integer :total_records
|
||||
t.integer :processed_records
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
15
db/schema.rb
15
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2021_01_26_121313) do
|
||||
ActiveRecord::Schema.define(version: 2021_02_01_150037) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
|
@ -241,6 +241,18 @@ ActiveRecord::Schema.define(version: 2021_01_26_121313) do
|
|||
t.index ["team_id"], name: "index_conversations_on_team_id"
|
||||
end
|
||||
|
||||
create_table "data_imports", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "data_type", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.text "processing_errors"
|
||||
t.integer "total_records"
|
||||
t.integer "processed_records"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["account_id"], name: "index_data_imports_on_account_id"
|
||||
end
|
||||
|
||||
create_table "email_templates", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "body", null: false
|
||||
|
@ -585,6 +597,7 @@ ActiveRecord::Schema.define(version: 2021_01_26_121313) do
|
|||
add_foreign_key "contact_inboxes", "inboxes"
|
||||
add_foreign_key "conversations", "contact_inboxes"
|
||||
add_foreign_key "conversations", "teams"
|
||||
add_foreign_key "data_imports", "accounts"
|
||||
add_foreign_key "team_members", "teams"
|
||||
add_foreign_key "team_members", "users"
|
||||
add_foreign_key "teams", "accounts"
|
||||
|
|
26
spec/assets/contacts.csv
Normal file
26
spec/assets/contacts.csv
Normal file
|
@ -0,0 +1,26 @@
|
|||
id,first_name,last_name,email,gender,ip_address,identifier
|
||||
1,Clarice,Uzzell,cuzzell0@mozilla.org,Genderfluid,70.61.11.201,bb4e11cd-0f23-49da-a123-dcc1fec6852c
|
||||
2,Marieann,Creegan,mcreegan1@cornell.edu,Genderfluid,168.186.4.241,e60bab4c-9fbb-47eb-8f75-42025b789c47
|
||||
3,Nancey,Windibank,nwindibank2@bluehost.com,Agender,73.44.41.59,f793e813-4210-4bf3-a812-711418de25d2
|
||||
4,Sibel,Stennine,sstennine3@yellowbook.com,Genderqueer,115.249.27.155,d6e35a2d-d093-4437-a577-7df76316b937
|
||||
5,Tina,O'Lunney,tolunney4@si.edu,Bigender,219.181.212.8,3540d40a-5567-4f28-af98-5583a7ddbc56
|
||||
6,Quinn,Neve,qneve5@army.mil,Genderfluid,231.210.115.166,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5
|
||||
7,Karylin,Gaunson,kgaunson6@tripod.com,Polygender,160.189.41.11,d24cac79-c81b-4b84-a33e-0441b7c6a981
|
||||
8,Jamison,Shenton,jshenton7@upenn.edu,Agender,53.94.18.201,29a7a8c0-c7f7-4af9-852f-761b1a784a7a
|
||||
9,Gavan,Threlfall,gthrelfall8@spotify.com,Genderfluid,18.87.247.249,847d4943-ddb5-47cc-8008-ed5092c675c5
|
||||
10,Katina,Hemmingway,khemmingway9@ameblo.jp,Non-binary,25.191.96.124,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048
|
||||
11,Jillian,Deinhard,jdeinharda@canalblog.com,Female,11.211.174.93,bd952787-1b05-411f-9975-b916ec0950cc
|
||||
12,Blake,Finden,bfindenb@wsj.com,Female,47.26.205.153,12c95613-e49d-4fa2-86fb-deabb6ebe600
|
||||
13,Liane,Maxworthy,lmaxworthyc@un.org,Non-binary,157.196.34.166,36b68e4c-40d6-4e09-bf59-7db3b27b18f0
|
||||
14,Martynne,Ledley,mledleyd@sourceforge.net,Polygender,109.231.152.148,1856bceb-cb36-415c-8ffc-0527f3f750d8
|
||||
15,Katharina,Ruffli,krufflie@huffingtonpost.com,Genderfluid,20.43.146.179,604de5c9-b154-4279-8978-41fb71f0f773
|
||||
16,Tucker,Simmance,tsimmancef@bbc.co.uk,Bigender,179.76.226.171,0a8fc3a7-4986-4a51-a503-6c7f974c90ad
|
||||
17,Wenona,Martinson,wmartinsong@census.gov,Genderqueer,92.243.194.160,0e5ea6e3-6824-4e78-a6f5-672847eafa17
|
||||
18,Gretna,Vedyasov,gvedyasovh@lycos.com,Female,25.22.86.101,6becf55b-a7b5-48f6-8788-b89cae85b066
|
||||
19,Lurline,Abdon,labdoni@archive.org,Genderqueer,150.249.116.118,afa9429f-9034-4b06-9efa-980e01906ebf
|
||||
20,Fiann,Norcliff,fnorcliffj@istockphoto.com,Female,237.167.197.197,59f72dec-14ba-4d6e-b17c-0d962e69ffac
|
||||
21,Zed,Linn,zlinnk@phoca.cz,Genderqueer,88.102.64.113,95f7bc56-be92-4c9c-ad58-eff3e63c7bea
|
||||
22,Averyl,Simyson,asimysonl@livejournal.com,Agender,141.248.89.29,bde1fe59-c9bd-440c-bb39-79fe61dac1d1
|
||||
23,Camella,Blackadder,cblackadderm@nifty.com,Polygender,118.123.138.115,0c981752-5857-487c-b9b5-5d0253df740a
|
||||
24,Aurie,Spatig,aspatign@printfriendly.com,Polygender,157.45.102.235,4cf22bfb-2c3f-41d1-9993-6e3758e457ba
|
||||
25,Adrienne,Bellard,abellardo@cnn.com,Male,170.73.198.47,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944
|
|
|
@ -3,7 +3,7 @@ require 'rails_helper'
|
|||
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/contact_inboxes', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox_1) { create(:inbox, account: account) }
|
||||
let(:channel_twilio_sms) { create(:channel_twilio_sms, account: account) }
|
||||
let(:channel_api) { create(:channel_api, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
|
@ -28,10 +28,10 @@ RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/contact_inboxes', typ
|
|||
expect(contact.reload.contact_inboxes.map(&:inbox_id)).to include(channel_api.inbox.id)
|
||||
end
|
||||
|
||||
it 'throws error when its not an api inbox' do
|
||||
it 'throws error for invalid source id' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/contact_inboxes",
|
||||
params: { inbox_id: inbox_1.id },
|
||||
params: { inbox_id: channel_twilio_sms.inbox.id },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(ContactInbox, :count).by(0)
|
||||
|
|
|
@ -43,6 +43,43 @@ RSpec.describe 'Contacts API', type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/contacts/import' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/import"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user with out permission' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/import",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'creates a data import' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/contacts.csv'), 'text/csv')
|
||||
post "/api/v1/accounts/#{account.id}/contacts/import",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { import_file: file }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.data_imports.count).to eq(1)
|
||||
expect(account.data_imports.first.import_file.attached?).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/contacts/active' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
|
|
@ -4,7 +4,9 @@ FactoryBot.define do
|
|||
account_sid { SecureRandom.uuid }
|
||||
sequence(:phone_number) { |n| "+123456789#{n}1" }
|
||||
medium { :sms }
|
||||
inbox
|
||||
account
|
||||
after(:build) do |channel|
|
||||
channel.inbox ||= create(:inbox, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,6 +4,18 @@ FactoryBot.define do
|
|||
factory :contact_inbox do
|
||||
contact
|
||||
inbox
|
||||
source_id { SecureRandom.uuid }
|
||||
|
||||
after(:build) { |contact_inbox| contact_inbox.source_id ||= generate_source_id(contact_inbox) }
|
||||
end
|
||||
end
|
||||
|
||||
def generate_source_id(contact_inbox)
|
||||
case contact_inbox.inbox.channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
contact_inbox.inbox.channel.medium == 'sms' ? Faker::PhoneNumber.cell_phone_in_e164 : "whatsapp:#{Faker::PhoneNumber.cell_phone_in_e164}"
|
||||
when 'Channel::Email'
|
||||
"#{SecureRandom.uuid}@acme.inc"
|
||||
else
|
||||
SecureRandom.uuid
|
||||
end
|
||||
end
|
||||
|
|
7
spec/factories/data_import.rb
Normal file
7
spec/factories/data_import.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
FactoryBot.define do
|
||||
factory :data_import do
|
||||
data_type { 'contacts' }
|
||||
import_file { Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/contacts.csv'), 'text/csv') }
|
||||
account
|
||||
end
|
||||
end
|
24
spec/jobs/data_import_job_spec.rb
Normal file
24
spec/jobs/data_import_job_spec.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe DataImportJob, type: :job do
|
||||
subject(:job) { described_class.perform_later(data_import) }
|
||||
|
||||
let!(:data_import) { create(:data_import) }
|
||||
|
||||
it 'queues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(data_import)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
it 'imports data into the account' do
|
||||
csv_length = CSV.parse(data_import.import_file.download, headers: true).length
|
||||
described_class.perform_now(data_import)
|
||||
expect(data_import.account.contacts.count).to eq(csv_length)
|
||||
expect(data_import.reload.total_records).to eq(csv_length)
|
||||
expect(data_import.reload.processed_records).to eq(csv_length)
|
||||
|
||||
# should generate pubsub tokens for contacts
|
||||
expect(data_import.account.contacts.last.pubsub_token).present?
|
||||
end
|
||||
end
|
13
spec/models/data_import_spec.rb
Normal file
13
spec/models/data_import_spec.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe DataImport, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'returns false for invalid data type' do
|
||||
expect(build(:data_import, data_type: 'Xyc').valid?).to eq false
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue