mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +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 | ||||
|   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 | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -87,6 +87,16 @@ | ||||
|         > | ||||
|           {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} | ||||
|         </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> | ||||
|   </header> | ||||
| @@ -127,6 +137,10 @@ export default { | ||||
|       type: Function, | ||||
|       default: () => {}, | ||||
|     }, | ||||
|     onExportSubmit: { | ||||
|       type: Function, | ||||
|       default: () => {}, | ||||
|     }, | ||||
|     onToggleFilter: { | ||||
|       type: Function, | ||||
|       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) => { | ||||
|     commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); | ||||
|     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 | ||||
|   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 | ||||
|   | ||||
| @@ -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 } | ||||
|  | ||||
|   | ||||
| @@ -11,6 +11,10 @@ class ContactPolicy < ApplicationPolicy | ||||
|     @account_user.administrator? | ||||
|   end | ||||
|  | ||||
|   def export? | ||||
|     @account_user.administrator? | ||||
|   end | ||||
|  | ||||
|   def search? | ||||
|     true | ||||
|   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: | ||||
|       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: | ||||
|   | ||||
| @@ -115,6 +115,7 @@ Rails.application.routes.draw do | ||||
|               get :search | ||||
|               post :filter | ||||
|               post :import | ||||
|               get :export | ||||
|             end | ||||
|             member do | ||||
|               get :contactable_inboxes | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										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 | ||||
|     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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Tejaswini Chile
					Tejaswini Chile