diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 8afd5b655..ba78cb805 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController head :ok 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 def active contacts = Current.account.contacts.where(id: ::OnlineStatusTracker diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 3599961e1..4def5ee2e 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -75,6 +75,10 @@ class ContactAPI extends ApiClient { destroyAvatar(contactId) { return axios.delete(`${this.url}/${contactId}/avatar`); } + + exportContacts() { + return axios.get(`${this.url}/export`); + } } export default new ContactAPI(); diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 042a1c1fc..feb6f0595 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -73,6 +73,13 @@ "SUCCESS_MESSAGE": "Contacts saved successfully", "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": { "CONFIRM": { "TITLE": "Confirm Deletion", diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue index a7e2c3e29..bec3ce529 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue @@ -5,6 +5,7 @@ :search-query="searchQuery" :segments-id="segmentsId" :on-search-submit="onSearchSubmit" + :on-export-submit="onExportSubmit" this-selected-contact-id="" :on-input-search="onInputSearch" :on-toggle-create="onToggleCreate" @@ -92,6 +93,7 @@ import filterQueryGenerator from '../../../../helper/filterQueryGenerator'; import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews'; import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events'; +import alertMixin from 'shared/mixins/alertMixin'; import countries from 'shared/constants/countries.js'; import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper'; @@ -110,6 +112,7 @@ export default { AddCustomViews, DeleteCustomViews, }, + mixins: [alertMixin], props: { label: { type: String, default: '' }, segmentsId: { @@ -386,6 +389,16 @@ export default { this.$store.dispatch('contacts/clearContactFilters'); 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() { // Here we are setting the params for edit segment modal to show the existing values. diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue index fd6dc8d52..46ab24a08 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue @@ -87,6 +87,16 @@ > {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} + + + {{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }} + @@ -127,6 +137,10 @@ export default { type: Function, default: () => {}, }, + onExportSubmit: { + type: Function, + default: () => {}, + }, onToggleFilter: { type: Function, default: () => {}, diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 7b898cbea..9e264d186 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -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) => { commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); try { diff --git a/app/jobs/account/contacts_export_job.rb b/app/jobs/account/contacts_export_job.rb new file mode 100644 index 000000000..0ae8b3892 --- /dev/null +++ b/app/jobs/account/contacts_export_job.rb @@ -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 diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index c0b3e1697..c1f7bd81c 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -43,6 +43,14 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer send_mail_with_liquid(to: admin_emails, subject: subject) and return 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 def admin_emails diff --git a/app/models/account.rb b/app/models/account.rb index e5453bfa3..564d2f7e2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -77,6 +77,8 @@ class Account < ApplicationRecord has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp' 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 status: { active: 0, suspended: 1 } diff --git a/app/policies/contact_policy.rb b/app/policies/contact_policy.rb index 71a967c6f..cd199012f 100644 --- a/app/policies/contact_policy.rb +++ b/app/policies/contact_policy.rb @@ -11,6 +11,10 @@ class ContactPolicy < ApplicationPolicy @account_user.administrator? end + def export? + @account_user.administrator? + end + def search? true end diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid b/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid new file mode 100644 index 000000000..dea003e46 --- /dev/null +++ b/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_export_complete.liquid @@ -0,0 +1,8 @@ +

Hi

+ + +

Your contact export file is ready to download.

+ +

+Click here to download the export file. +

diff --git a/config/locales/en.yml b/config/locales/en.yml index daa5a295c..b4ea686d9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -52,6 +52,8 @@ en: contacts: import: failed: File is blank + export: + success: We will notify you once contacts export file is ready to view. email: invalid: Invalid email phone_number: diff --git a/config/routes.rb b/config/routes.rb index 73eccd3ee..9d862da2e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,6 +115,7 @@ Rails.application.routes.draw do get :search post :filter post :import + get :export end member do get :contactable_inboxes diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index f6141000e..cd5f977ca 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -168,6 +168,52 @@ RSpec.describe 'Contacts API', type: :request do 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 context 'when it is an unauthenticated user' do it 'returns unauthorized' do diff --git a/spec/jobs/account/contacts_export_job_spec.rb b/spec/jobs/account/contacts_export_job_spec.rb new file mode 100644 index 000000000..d1c7dcff8 --- /dev/null +++ b/spec/jobs/account/contacts_export_job_spec.rb @@ -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 diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index 7190678cc..2bbb25b84 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -10,6 +10,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do before do allow(described_class).to receive(:new).and_return(class_instance) allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) + Account::ContactsExportJob.perform_now(account.id, []) end describe 'slack_disconnect' do @@ -55,4 +56,17 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do expect(mail.to).to eq([administrator.email]) 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