feat: Contact Exports (#7258)

This commit is contained in:
Tejaswini Chile
2023-06-13 09:18:43 +05:30
committed by GitHub
parent 429ec7194f
commit 23ca6d56f9
16 changed files with 240 additions and 0 deletions

View File

@@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
head :ok, message: I18n.t('errors.contacts.export.success')
end
# returns online contacts # returns online contacts
def active def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker

View File

@@ -75,6 +75,10 @@ class ContactAPI extends ApiClient {
destroyAvatar(contactId) { destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`); return axios.delete(`${this.url}/${contactId}/avatar`);
} }
exportContacts() {
return axios.get(`${this.url}/export`);
}
} }
export default new ContactAPI(); export default new ContactAPI();

View File

@@ -73,6 +73,13 @@
"SUCCESS_MESSAGE": "Contacts saved successfully", "SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again" "ERROR_MESSAGE": "There was an error, please try again"
}, },
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"DELETE_NOTE": { "DELETE_NOTE": {
"CONFIRM": { "CONFIRM": {
"TITLE": "Confirm Deletion", "TITLE": "Confirm Deletion",

View File

@@ -5,6 +5,7 @@
:search-query="searchQuery" :search-query="searchQuery"
:segments-id="segmentsId" :segments-id="segmentsId"
:on-search-submit="onSearchSubmit" :on-search-submit="onSearchSubmit"
:on-export-submit="onExportSubmit"
this-selected-contact-id="" this-selected-contact-id=""
:on-input-search="onInputSearch" :on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate" :on-toggle-create="onToggleCreate"
@@ -92,6 +93,7 @@ import filterQueryGenerator from '../../../../helper/filterQueryGenerator';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews'; import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import alertMixin from 'shared/mixins/alertMixin';
import countries from 'shared/constants/countries.js'; import countries from 'shared/constants/countries.js';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper'; import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
@@ -110,6 +112,7 @@ export default {
AddCustomViews, AddCustomViews,
DeleteCustomViews, DeleteCustomViews,
}, },
mixins: [alertMixin],
props: { props: {
label: { type: String, default: '' }, label: { type: String, default: '' },
segmentsId: { segmentsId: {
@@ -386,6 +389,16 @@ export default {
this.$store.dispatch('contacts/clearContactFilters'); this.$store.dispatch('contacts/clearContactFilters');
this.fetchContacts(this.pageParameter); this.fetchContacts(this.pageParameter);
}, },
onExportSubmit() {
try {
this.$store.dispatch('contacts/export');
this.showAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
error.message || this.$t('EXPORT_CONTACTS.ERROR_MESSAGE')
);
}
},
setParamsForEditSegmentModal() { setParamsForEditSegmentModal() {
// Here we are setting the params for edit segment modal to show the existing values. // Here we are setting the params for edit segment modal to show the existing values.

View File

@@ -87,6 +87,16 @@
> >
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button> </woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="upload"
class="clear"
@click="onExportSubmit"
>
{{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div> </div>
</div> </div>
</header> </header>
@@ -127,6 +137,10 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
onExportSubmit: {
type: Function,
default: () => {},
},
onToggleFilter: { onToggleFilter: {
type: Function, type: Function,
default: () => {}, default: () => {},

View File

@@ -140,6 +140,20 @@ export const actions = {
} }
}, },
export: async ({ commit }) => {
try {
await ContactAPI.exportContacts();
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
throw new Error(error);
}
}
},
delete: async ({ commit }, id) => { delete: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
try { try {

View File

@@ -0,0 +1,47 @@
class Account::ContactsExportJob < ApplicationJob
queue_as :low
def perform(account_id, column_names)
account = Account.find(account_id)
headers = valid_headers(column_names)
generate_csv(account, headers)
file_url = account_contact_export_url(account)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).contact_export_complete(file_url)&.deliver_later
end
def generate_csv(account, headers)
csv_data = CSV.generate do |csv|
csv << headers
account.contacts.each do |contact|
csv << headers.map { |header| contact.send(header) }
end
end
attach_export_file(account, csv_data)
end
def valid_headers(column_names)
columns = (column_names.presence || default_columns)
headers = columns.map { |column| column if Contact.column_names.include?(column) }
headers.compact
end
def attach_export_file(account, csv_data)
return if csv_data.blank?
account.contacts_export.attach(
io: StringIO.new(csv_data),
filename: "#{account.name}_#{account.id}_contacts.csv",
content_type: 'text/csv'
)
end
def account_contact_export_url(account)
Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
end
def default_columns
%w[id name email phone_number]
end
end

View File

@@ -43,6 +43,14 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: admin_emails, subject: subject) and return send_mail_with_liquid(to: admin_emails, subject: subject) and return
end end
def contact_export_complete(file_url)
return unless smtp_config_set_or_development?
@action_url = file_url
subject = "Your contact's export file is available to download."
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
private private
def admin_emails def admin_emails

View File

@@ -77,6 +77,8 @@ class Account < ApplicationRecord
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp' has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
has_many :working_hours, dependent: :destroy_async has_many :working_hours, dependent: :destroy_async
has_one_attached :contacts_export
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
enum status: { active: 0, suspended: 1 } enum status: { active: 0, suspended: 1 }

View File

@@ -11,6 +11,10 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
def export?
@account_user.administrator?
end
def search? def search?
true true
end end

View File

@@ -0,0 +1,8 @@
<p>Hi</p>
<p>Your contact export file is ready to download.</p>
<p>
Click <a href="{{ action_url }}">here</a> to download the export file.
</p>

View File

@@ -52,6 +52,8 @@ en:
contacts: contacts:
import: import:
failed: File is blank failed: File is blank
export:
success: We will notify you once contacts export file is ready to view.
email: email:
invalid: Invalid email invalid: Invalid email
phone_number: phone_number:

View File

@@ -115,6 +115,7 @@ Rails.application.routes.draw do
get :search get :search
post :filter post :filter
post :import post :import
get :export
end end
member do member do
get :contactable_inboxes get :contactable_inboxes

View File

@@ -168,6 +168,52 @@ RSpec.describe 'Contacts API', type: :request do
end end
end end
describe 'POST /api/v1/accounts/{account.id}/contacts/export' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/contacts/export"
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
get "/api/v1/accounts/#{account.id}/contacts/export",
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 'enqueues a contact export job' do
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, nil).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
params: { column_names: nil }
expect(response).to have_http_status(:success)
end
it 'enqueues a contact export job with sent_columns' do
expect(Account::ContactsExportJob).to receive(:perform_later).with(account.id, %w[phone_number email]).once
get "/api/v1/accounts/#{account.id}/contacts/export",
headers: admin.create_new_auth_token,
params: { column_names: %w[phone_number email] }
expect(response).to have_http_status(:success)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/contacts/active' do describe 'GET /api/v1/accounts/{account.id}/contacts/active' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do

View File

@@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe Account::ContactsExportJob do
subject(:job) { described_class.perform_later }
let!(:account) { create(:account) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when export_contacts' do
before do
create(:contact, account: account, phone_number: '+910808080818', email: 'test1@text.example')
8.times do
create(:contact, account: account)
end
create(:contact, account: account, phone_number: '+910808080808', email: 'test2@text.example')
end
it 'generates CSV file and attach to account' do
mailer = double
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).with(account: account).and_return(mailer)
allow(mailer).to receive(:contact_export_complete)
described_class.perform_now(account.id, [])
file_url = Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
expect(account.contacts_export).to be_present
expect(file_url).to be_present
expect(mailer).to have_received(:contact_export_complete).with(file_url)
end
it 'generates valid data export file' do
described_class.perform_now(account.id, [])
csv_data = CSV.parse(account.contacts_export.download, headers: true)
first_row = csv_data[0]
last_row = csv_data[csv_data.length - 1]
first_contact = account.contacts.first
last_contact = account.contacts.last
expect(csv_data.length).to eq(account.contacts.count)
expect([first_row['email'], last_row['email']]).to contain_exactly(first_contact.email, last_contact.email)
expect([first_row['phone_number'], last_row['phone_number']]).to contain_exactly(first_contact.phone_number, last_contact.phone_number)
end
end
end

View File

@@ -10,6 +10,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
before do before do
allow(described_class).to receive(:new).and_return(class_instance) allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
Account::ContactsExportJob.perform_now(account.id, [])
end end
describe 'slack_disconnect' do describe 'slack_disconnect' do
@@ -55,4 +56,17 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
expect(mail.to).to eq([administrator.email]) expect(mail.to).to eq([administrator.email])
end end
end end
describe 'contact_export_complete' do
let!(:file_url) { Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export) }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
end end