mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Contact Exports (#7258)
This commit is contained in:
		| @@ -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 | ||||||
|   | |||||||
| @@ -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(); | ||||||
|   | |||||||
| @@ -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", | ||||||
|   | |||||||
| @@ -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. | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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: () => {}, | ||||||
|   | |||||||
| @@ -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 { | ||||||
|   | |||||||
							
								
								
									
										47
									
								
								app/jobs/account/contacts_export_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/jobs/account/contacts_export_job.rb
									
									
									
									
									
										Normal 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 | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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> | ||||||
| @@ -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: | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								spec/jobs/account/contacts_export_job_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								spec/jobs/account/contacts_export_job_spec.rb
									
									
									
									
									
										Normal 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 | ||||||
| @@ -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 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Tejaswini Chile
					Tejaswini Chile