From 4f51a46c2ba54ebc59988f81134e9619603add38 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Thu, 23 Sep 2021 12:52:49 +0530 Subject: [PATCH] feat: Ability to delete a contact (#2984) This change allows the administrator user to delete a contact and its related data like conversations, contact inboxes, and reports. Fixes #1929 --- .../api/v1/accounts/contacts_controller.rb | 18 +- .../dashboard/helper/actionCable.js | 9 + .../dashboard/i18n/locale/en/contact.json | 16 ++ .../contacts/components/ContactInfoPanel.vue | 2 +- .../conversation/contact/ContactInfo.vue | 159 +++++++++++++++--- .../store/modules/contactConversations.js | 3 + .../store/modules/contacts/actions.js | 23 +++ .../dashboard/store/modules/contacts/index.js | 1 + .../store/modules/contacts/mutations.js | 6 + .../store/modules/conversations/index.js | 7 + .../modules/specs/contacts/actions.spec.js | 36 ++++ .../dashboard/store/mutation-types.js | 3 + app/listeners/action_cable_listener.rb | 7 + app/models/contact.rb | 8 +- app/policies/contact_policy.rb | 4 + config/locales/en.yml | 4 +- config/routes.rb | 2 +- lib/events/types.rb | 1 + .../v1/accounts/contacts_controller_spec.rb | 49 ++++++ spec/listeners/action_cable_listener_spec.rb | 15 ++ swagger/paths/contact/crud.yml | 19 +++ swagger/swagger.json | 27 +++ 22 files changed, 387 insertions(+), 32 deletions(-) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 82b92f090..561b224c3 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search] - before_action :fetch_contact, only: [:show, :update, :contactable_inboxes] + before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes] before_action :set_include_contact_inboxes, only: [:index, :search] def index @@ -73,6 +73,18 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController }, status: :unprocessable_entity end + def destroy + if ::OnlineStatusTracker.get_presence( + @contact.account.id, 'Contact', @contact.id + ) + return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) }, + :unprocessable_entity) + end + + @contact.destroy! + head :ok + end + private # TODO: Move this to a finder class @@ -137,4 +149,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def fetch_contact @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) end + + def render_error(error, error_status) + render json: error, status: error_status + end end diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index afddb1e88..3a86ad3ea 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -19,6 +19,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.typing_off': this.onTypingOff, 'conversation.contact_changed': this.onConversationContactChange, 'presence.update': this.onPresenceUpdate, + 'contact.deleted': this.onContactDelete, }; } @@ -115,6 +116,14 @@ class ActionCableConnector extends BaseActionCableConnector { fetchConversationStats = () => { bus.$emit('fetch_conversation_stats'); }; + + onContactDelete = data => { + this.app.$store.dispatch( + 'contacts/deleteContactThroughConversations', + data.id + ); + this.fetchConversationStats(); + }; } export default { diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index e00e01647..d08b363ff 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -54,6 +54,22 @@ "TITLE": "Create new contact", "DESC": "Add basic information details about the contact." }, + "DELETE_CONTACT": { + "BUTTON_LABEL": "Delete Contact", + "TITLE": "Delete contact", + "DESC": "Delete contact details", + "CONFIRM": { + "TITLE": "Confirm Deletion", + "MESSAGE": "Are you sure to delete ", + "PLACE_HOLDER": "Please type {contactName} to confirm", + "YES": "Yes, Delete ", + "NO": "No, Keep " + }, + "API": { + "SUCCESS_MESSAGE": "Contact deleted successfully", + "ERROR_MESSAGE": "Could not delete contact. Please try again later." + } + }, "CONTACT_FORM": { "FORM": { "SUBMIT": "Submit", diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue index be79067e2..27f2f14c2 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue @@ -3,7 +3,7 @@ - + - - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - -
- - {{ $t('CONTACT_PANEL.NEW_MESSAGE') }} - - - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - +
+
+ + {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} + +
+
+ + {{ $t('DELETE_CONTACT.BUTTON_LABEL') }} + +
+
+
+
+ + + +
+ @@ -179,17 +269,32 @@ export default { .contact-actions { margin-top: var(--space-small); } -.button.edit-contact { + +.edit-contact { margin-left: var(--space-medium); } -.button.new-message { - margin-right: var(--space-small); +.delete-contact { + margin-left: var(--space-medium); } .contact-actions { display: flex; align-items: center; width: 100%; + + .new-message { + font-size: var(--font-size-medium); + } + + .edit-contact { + margin-left: var(--space-small); + font-size: var(--font-size-medium); + } + + .delete-contact { + margin-left: var(--space-small); + font-size: var(--font-size-medium); + } } diff --git a/app/javascript/dashboard/store/modules/contactConversations.js b/app/javascript/dashboard/store/modules/contactConversations.js index d483637fc..eefae3165 100644 --- a/app/javascript/dashboard/store/modules/contactConversations.js +++ b/app/javascript/dashboard/store/modules/contactConversations.js @@ -82,6 +82,9 @@ export const mutations = { const conversations = $state.records[id] || []; Vue.set($state.records, id, [...conversations, data]); }, + [types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => { + Vue.delete($state.records, id); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 09761c443..fae33406a 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -83,6 +83,21 @@ export const actions = { } }, + delete: async ({ commit }, id) => { + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); + try { + await ContactAPI.delete(id); + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false }); + } catch (error) { + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false }); + if (error.response?.data?.message) { + throw new Error(error.response.data.message); + } else { + throw new Error(error); + } + } + }, + fetchContactableInbox: async ({ commit }, id) => { commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true }); try { @@ -110,4 +125,12 @@ export const actions = { setContact({ commit }, data) { commit(types.SET_CONTACT_ITEM, data); }, + + deleteContactThroughConversations: ({ commit }, id) => { + commit(types.DELETE_CONTACT, id); + commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true }); + commit(`contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, id, { + root: true, + }); + }, }; diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js index d5264169e..f1982e4be 100644 --- a/app/javascript/dashboard/store/modules/contacts/index.js +++ b/app/javascript/dashboard/store/modules/contacts/index.js @@ -13,6 +13,7 @@ const state = { isFetchingItem: false, isFetchingInboxes: false, isUpdating: false, + isDeleting: false, }, sortOrder: [], }; diff --git a/app/javascript/dashboard/store/modules/contacts/mutations.js b/app/javascript/dashboard/store/modules/contacts/mutations.js index 46f4d94fa..9e8e64e6d 100644 --- a/app/javascript/dashboard/store/modules/contacts/mutations.js +++ b/app/javascript/dashboard/store/modules/contacts/mutations.js @@ -46,6 +46,12 @@ export const mutations = { Vue.set($state.records, data.id, data); }, + [types.DELETE_CONTACT]: ($state, id) => { + const index = $state.sortOrder.findIndex(item => item === id); + Vue.delete($state.sortOrder, index); + Vue.delete($state.records, id); + }, + [types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => { Object.values($state.records).forEach(element => { const availabilityStatus = data[element.id]; diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index f0005c627..f2fd0fd43 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -177,6 +177,13 @@ export const mutations = { Vue.set(chat, 'can_reply', canReply); } }, + + [types.default.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) { + const chats = _state.allConversations.filter( + c => c.meta.sender.id !== contactId + ); + Vue.set(_state, 'allConversations', chats); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js index 4bc8b6723..03d49d8d0 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js @@ -139,6 +139,27 @@ describe('#actions', () => { }); }); + describe('#delete', () => { + it('sends correct mutations if API is success', async () => { + axios.delete.mockResolvedValue(); + await actions.delete({ commit }, contactList[0].id); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isDeleting: true }], + [types.SET_CONTACT_UI_FLAG, { isDeleting: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.delete({ commit }, contactList[0].id) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isDeleting: true }], + [types.SET_CONTACT_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + describe('#setContact', () => { it('returns correct mutations', () => { const data = { id: 1, name: 'john doe', availability_status: 'online' }; @@ -146,4 +167,19 @@ describe('#actions', () => { expect(commit.mock.calls).toEqual([[types.SET_CONTACT_ITEM, data]]); }); }); + + describe('#deleteContactThroughConversations', () => { + it('returns correct mutations', () => { + actions.deleteContactThroughConversations({ commit }, contactList[0].id); + expect(commit.mock.calls).toEqual([ + [types.DELETE_CONTACT, contactList[0].id], + [types.CLEAR_CONTACT_CONVERSATIONS, contactList[0].id, { root: true }], + [ + `contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, + contactList[0].id, + { root: true }, + ], + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index c364f7e4d..eff36b0d2 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -18,6 +18,7 @@ export default { CHANGE_CHAT_STATUS_FILTER: 'CHANGE_CHAT_STATUS_FILTER', UPDATE_ASSIGNEE: 'UPDATE_ASSIGNEE', UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT', + CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS', SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW', CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW', @@ -101,6 +102,7 @@ export default { SET_CONTACTS: 'SET_CONTACTS', CLEAR_CONTACTS: 'CLEAR_CONTACTS', EDIT_CONTACT: 'EDIT_CONTACT', + DELETE_CONTACT: 'DELETE_CONTACT', UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE', // Notifications @@ -119,6 +121,7 @@ export default { SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS', ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION', + DELETE_CONTACT_CONVERSATION: 'DELETE_CONTACT_CONVERSATION', // Contact Label SET_CONTACT_LABELS_UI_FLAG: 'SET_CONTACT_LABELS_UI_FLAG', diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index dfedc5ba7..a2388774f 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -111,6 +111,13 @@ class ActionCableListener < BaseListener broadcast(account, tokens, CONTACT_MERGED, contact.push_event_data) end + def contact_deleted(event) + contact, account = extract_contact_and_account(event) + tokens = user_tokens(account, account.agents) + + broadcast(account, tokens, CONTACT_DELETED, contact.push_event_data) + end + private def typing_event_listener_tokens(account, conversation, user) diff --git a/app/models/contact.rb b/app/models/contact.rb index b81b520f2..267dfab72 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -48,6 +48,7 @@ class Contact < ApplicationRecord before_validation :prepare_email_attribute after_create_commit :dispatch_create_event, :ip_lookup after_update_commit :dispatch_update_event + after_destroy_commit :dispatch_destroy_event def get_source_id(inbox_id) contact_inboxes.find_by!(inbox_id: inbox_id).source_id @@ -73,7 +74,8 @@ class Contact < ApplicationRecord id: id, name: name, avatar: avatar_url, - type: 'contact' + type: 'contact', + account: account.webhook_data } end @@ -98,4 +100,8 @@ class Contact < ApplicationRecord def dispatch_update_event Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self) end + + def dispatch_destroy_event + Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self) + end end diff --git a/app/policies/contact_policy.rb b/app/policies/contact_policy.rb index fb4cd4009..9013014d7 100644 --- a/app/policies/contact_policy.rb +++ b/app/policies/contact_policy.rb @@ -30,4 +30,8 @@ class ContactPolicy < ApplicationPolicy def create? true end + + def destroy? + @account_user.administrator? + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8e3d5d19f..494622381 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -92,7 +92,9 @@ en: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" - + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/routes.rb b/config/routes.rb index a3239d962..36fc15e3d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,7 +76,7 @@ Rails.application.routes.draw do end end - resources :contacts, only: [:index, :show, :update, :create] do + resources :contacts, only: [:index, :show, :update, :create, :destroy] do collection do get :active get :search diff --git a/lib/events/types.rb b/lib/events/types.rb index c47691cd2..9c0f04fce 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -35,6 +35,7 @@ module Events::Types CONTACT_CREATED = 'contact.created' CONTACT_UPDATED = 'contact.updated' CONTACT_MERGED = 'contact.merged' + CONTACT_DELETED = 'contact.deleted' # agent events AGENT_ADDED = 'agent.added' diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index af94fcd19..13d2649f3 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -376,4 +376,53 @@ RSpec.describe 'Contacts API', type: :request do end end end + + describe 'DELETE /api/v1/accounts/{account.id}/contacts/:id', :contact_delete do + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}" + + 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) } + let(:agent) { create(:user, account: account, role: :agent) } + + it 'deletes the contact for administrator user' do + allow(::OnlineStatusTracker).to receive(:get_presence).and_return(false) + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: admin.create_new_auth_token + + expect(contact.conversations).to be_empty + expect(contact.inboxes).to be_empty + expect(contact.contact_inboxes).to be_empty + expect(contact.csat_survey_responses).to be_empty + expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(response).to have_http_status(:success) + end + + it 'does not delete the contact if online' do + allow(::OnlineStatusTracker).to receive(:get_presence).and_return(true) + + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns unauthorized for agent user' do + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index a70ff6675..8e7aed2a1 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -65,4 +65,19 @@ describe ActionCableListener do listener.conversation_typing_off(event) end end + + describe '#contact_deleted' do + let(:event_name) { :'contact.deleted' } + let!(:contact) { create(:contact, account: account) } + let!(:event) { Events::Base.new(event_name, Time.zone.now, contact: contact) } + + it 'sends message to account admins, inbox agents' do + expect(ActionCableBroadcastJob).to receive(:perform_later).with( + [agent.pubsub_token, admin.pubsub_token], + 'contact.deleted', + contact.push_event_data.merge(account_id: account.id) + ) + listener.contact_deleted(event) + end + end end diff --git a/swagger/paths/contact/crud.yml b/swagger/paths/contact/crud.yml index fafa89cb4..7f0089b4b 100644 --- a/swagger/paths/contact/crud.yml +++ b/swagger/paths/contact/crud.yml @@ -48,3 +48,22 @@ put: description: Contact not found 403: description: Access denied + +delete: + tags: + - Contact + operationId: contactDelete + summary: Delete Contact + parameters: + - name: id + in: path + type: number + description: ID of the contact + required: true + responses: + 200: + description: Success + 401: + description: Unauthorized + 404: + description: Contact not found \ No newline at end of file diff --git a/swagger/swagger.json b/swagger/swagger.json index d9254d8e5..c1ffa0835 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1249,6 +1249,33 @@ "description": "Access denied" } } + }, + "delete": { + "tags": [ + "Contact" + ], + "operationId": "contactDelete", + "summary": "Delete Contact", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "number", + "description": "ID of the contact", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Contact not found" + } + } } }, "/api/v1/accounts/{account_id}/contacts/{id}/conversations": {