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') }}
-
-
+
@@ -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": {