diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index 8753918fc..e27869d82 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save!
end
+ def destroy
+ authorize @conversation, :destroy?
+ ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
+ head :ok
+ end
+
private
def permitted_update_params
diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js
index 1a87e6d96..0f539bfa9 100644
--- a/app/javascript/dashboard/api/inbox/conversation.js
+++ b/app/javascript/dashboard/api/inbox/conversation.js
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
+
+ delete(conversationId) {
+ return axios.delete(`${this.url}/${conversationId}`);
+ }
}
export default new ConversationApi();
diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue
index 089d9e74f..098bc41c1 100644
--- a/app/javascript/dashboard/components/ChatList.vue
+++ b/app/javascript/dashboard/components/ChatList.vue
@@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
+import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
+const route = useRoute();
const store = useStore();
const conversationListRef = ref(null);
@@ -646,6 +648,30 @@ function openLastItemAfterDeleteInFolder() {
}
}
+function redirectToConversationList() {
+ const {
+ params: { accountId, inbox_id: inboxId, label, teamId },
+ name,
+ } = route;
+
+ let conversationType = '';
+ if (isOnMentionsView({ route: { name } })) {
+ conversationType = 'mention';
+ } else if (isOnUnattendedView({ route: { name } })) {
+ conversationType = 'unattended';
+ }
+ router.push(
+ conversationListPageURL({
+ accountId,
+ conversationType: conversationType,
+ customViewId: props.foldersId,
+ inboxId,
+ label,
+ teamId,
+ })
+ );
+}
+
async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', {
priority,
@@ -670,26 +696,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
- const {
- params: { accountId, inbox_id: inboxId, label, teamId },
- name,
- } = useRoute();
- let conversationType = '';
- if (isOnMentionsView({ route: { name } })) {
- conversationType = 'mention';
- } else if (isOnUnattendedView({ route: { name } })) {
- conversationType = 'unattended';
- }
- router.push(
- conversationListPageURL({
- accountId,
- conversationType: conversationType,
- customViewId: props.foldersId,
- inboxId,
- label,
- teamId,
- })
- );
+ redirectToConversationList();
} catch (error) {
// Ignore error
}
@@ -703,6 +710,7 @@ async function markAsRead(conversationId) {
// Ignore error
}
}
+
async function onAssignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
@@ -764,6 +772,26 @@ onMounted(() => {
}
});
+const deleteConversationDialogRef = ref(null);
+const selectedConversationId = ref(null);
+
+async function deleteConversation() {
+ try {
+ await store.dispatch('deleteConversation', selectedConversationId.value);
+ redirectToConversationList();
+ selectedConversationId.value = null;
+ deleteConversationDialogRef.value.close();
+ useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
+ } catch (error) {
+ useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
+ }
+}
+
+const handleDelete = conversationId => {
+ selectedConversationId.value = conversationId;
+ deleteConversationDialogRef.value.open();
+};
+
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -775,6 +803,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
+provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
@@ -938,6 +967,19 @@ watch(conversationFilters, (newVal, oldVal) => {
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
index 698690dad..4840b459f 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
@@ -78,6 +78,7 @@ export default {
'markAsRead',
'assignPriority',
'updateConversationStatus',
+ 'deleteConversation',
],
data() {
return {
@@ -237,6 +238,10 @@ export default {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
+ async deleteConversation() {
+ this.$emit('deleteConversation', this.chat.id);
+ this.closeContextMenu();
+ },
},
};
@@ -363,6 +368,7 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
+ @delete-conversation="deleteConversation"
/>
diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue
index fccff3457..6c788bbd2 100644
--- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue
@@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
+import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
components: {
@@ -45,7 +46,14 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
+ 'deleteConversation',
],
+ setup() {
+ const { isAdmin } = useAdmin();
+ return {
+ isAdmin,
+ };
+ },
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -121,6 +129,11 @@ export default {
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
+ deleteOption: {
+ key: 'delete',
+ icon: 'delete',
+ label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
+ },
};
},
computed: {
@@ -178,6 +191,9 @@ export default {
assignPriority(priority) {
this.$emit('assignPriority', priority);
},
+ deleteConversation() {
+ this.$emit('deleteConversation', this.chatId);
+ },
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -277,5 +293,13 @@ export default {
@click.stop="$emit('assignTeam', team)"
/>
+
+
+
+
diff --git a/app/javascript/dashboard/helper/auditlogHelper.js b/app/javascript/dashboard/helper/auditlogHelper.js
index 6f5a4dede..706d09ba2 100644
--- a/app/javascript/dashboard/helper/auditlogHelper.js
+++ b/app/javascript/dashboard/helper/auditlogHelper.js
@@ -36,6 +36,7 @@ const translationKeys = {
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
+ 'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
};
function extractAttrChange(attrChange) {
@@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
+ if (auditableType === 'conversation' && action === 'destroy') {
+ translationPayload.id =
+ auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
+ }
+
if (auditableType === 'accountuser') {
translationPayload = handleAccountUser(
auditLogItem,
diff --git a/app/javascript/dashboard/i18n/locale/en/auditLogs.json b/app/javascript/dashboard/i18n/locale/en/auditLogs.json
index 8194c667c..f85ad2a3e 100644
--- a/app/javascript/dashboard/i18n/locale/en/auditLogs.json
+++ b/app/javascript/dashboard/i18n/locale/en/auditLogs.json
@@ -69,6 +69,9 @@
},
"ACCOUNT": {
"EDIT": "{agentName} updated the account configuration (#{id})"
+ },
+ "CONVERSATION": {
+ "DELETE": "{agentName} deleted conversation #{id}"
}
}
}
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 26ec6dc16..fdb7fc07a 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -118,6 +118,11 @@
"FAILED": "Couldn't change priority. Please try again."
}
},
+ "DELETE_CONVERSATION": {
+ "TITLE": "Delete conversation #{conversationId}",
+ "DESCRIPTION": "Are you sure you want to delete this conversation?",
+ "CONFIRM": "Delete"
+ },
"CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved",
@@ -134,6 +139,7 @@
"ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
+ "DELETE": "Delete conversation",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -208,6 +214,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed",
+ "SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
+ "FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 8296f25de..d8ad826ca 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -327,6 +327,16 @@ const actions = {
}
},
+ deleteConversation: async ({ commit, dispatch }, conversationId) => {
+ try {
+ await ConversationApi.delete(conversationId);
+ commit(types.DELETE_CONVERSATION, conversationId);
+ dispatch('conversationStats/get', {}, { root: true });
+ } catch (error) {
+ throw new Error(error);
+ }
+ },
+
addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state;
const {
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index fc6f10979..2ee6fd061 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -204,6 +204,12 @@ export const mutations = {
_state.allConversations.push(conversation);
},
+ [types.DELETE_CONVERSATION](_state, conversationId) {
+ _state.allConversations = _state.allConversations.filter(
+ c => c.id !== conversationId
+ );
+ },
+
[types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id);
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
index dc2bf8d3a..161ae914b 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
@@ -513,6 +513,28 @@ describe('#deleteMessage', () => {
expect(commit.mock.calls).toEqual([]);
});
+ describe('#deleteConversation', () => {
+ it('send correct actions if API is success', async () => {
+ axios.delete.mockResolvedValue({
+ data: { id: 1 },
+ });
+ await actions.deleteConversation({ commit, dispatch }, 1);
+ expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]);
+ expect(dispatch.mock.calls).toEqual([
+ ['conversationStats/get', {}, { root: true }],
+ ]);
+ });
+
+ it('send no actions if API is error', async () => {
+ axios.delete.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(
+ actions.deleteConversation({ commit, dispatch }, 1)
+ ).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([]);
+ expect(dispatch.mock.calls).toEqual([]);
+ });
+ });
+
describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
index 091dc3daa..b8660b20d 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
@@ -884,6 +884,17 @@ describe('#mutations', () => {
});
});
+ describe('#DELETE_CONVERSATION', () => {
+ it('should delete a conversation', () => {
+ const state = {
+ allConversations: [{ id: 1, messages: [] }],
+ };
+
+ mutations[types.DELETE_CONVERSATION](state, 1);
+ expect(state.allConversations).toEqual([]);
+ });
+ });
+
describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => {
const state = {
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index f3817c45a..a63fec2d1 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -56,6 +56,7 @@ export default {
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
+ DELETE_CONVERSATION: 'DELETE_CONVERSATION',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 3f1a20037..922118b09 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -305,5 +305,6 @@ class Conversation < ApplicationRecord
end
end
+Conversation.include_mod_with('Audit::Conversation')
Conversation.include_mod_with('Concerns::Conversation')
Conversation.prepend_mod_with('Conversation')
diff --git a/app/policies/conversation_policy.rb b/app/policies/conversation_policy.rb
index 133cb3b02..931e17435 100644
--- a/app/policies/conversation_policy.rb
+++ b/app/policies/conversation_policy.rb
@@ -2,4 +2,8 @@ class ConversationPolicy < ApplicationPolicy
def index?
true
end
+
+ def destroy?
+ @account_user&.administrator?
+ end
end
diff --git a/config/routes.rb b/config/routes.rb
index ebec6894b..12ce70d77 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -98,7 +98,7 @@ Rails.application.routes.draw do
namespace :channels do
resource :twilio_channel, only: [:create]
end
- resources :conversations, only: [:index, :create, :show, :update] do
+ resources :conversations, only: [:index, :create, :show, :update, :destroy] do
collection do
get :meta
get :search
diff --git a/enterprise/app/jobs/enterprise/delete_object_job.rb b/enterprise/app/jobs/enterprise/delete_object_job.rb
index d7a7f2f17..147fa0d43 100644
--- a/enterprise/app/jobs/enterprise/delete_object_job.rb
+++ b/enterprise/app/jobs/enterprise/delete_object_job.rb
@@ -4,7 +4,7 @@ module Enterprise::DeleteObjectJob
end
def create_audit_entry(object, user, ip)
- return unless ['Inbox'].include?(object.class.to_s) && user.present?
+ return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present?
Enterprise::AuditLog.create(
auditable: object,
diff --git a/enterprise/app/models/enterprise/audit/conversation.rb b/enterprise/app/models/enterprise/audit/conversation.rb
new file mode 100644
index 000000000..b1e096beb
--- /dev/null
+++ b/enterprise/app/models/enterprise/audit/conversation.rb
@@ -0,0 +1,7 @@
+module Enterprise::Audit::Conversation
+ extend ActiveSupport::Concern
+
+ included do
+ audited only: [], on: [:destroy]
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index a3697be84..38bd649fe 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -926,4 +926,63 @@ RSpec.describe 'Conversations API', type: :request do
end
end
end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/conversations/:id' do
+ let(:conversation) { create(:conversation, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated agent' do
+ before do
+ create(:inbox_member, user: agent, inbox: conversation.inbox)
+ end
+
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ response_body = response.parsed_body
+ expect(response_body['error']).to eq('You are not authorized to do this action')
+ end
+ end
+
+ context 'when it is an authenticated administrator' do
+ before do
+ create(:inbox_member, user: administrator, inbox: conversation.inbox)
+ end
+
+ it 'successfully deletes the conversation' do
+ expect do
+ delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
+ headers: administrator.create_new_auth_token,
+ as: :json
+ end.to have_enqueued_job(DeleteObjectJob).with(conversation, administrator, anything)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it 'can delete conversations from inboxes without direct access' do
+ other_inbox = create(:inbox, account: account)
+ other_conversation = create(:conversation, account: account, inbox: other_inbox)
+
+ expect do
+ delete "/api/v1/accounts/#{account.id}/conversations/#{other_conversation.display_id}",
+ headers: administrator.create_new_auth_token,
+ as: :json
+ end.to have_enqueued_job(DeleteObjectJob).with(other_conversation, administrator, anything)
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+ end
end
diff --git a/spec/models/enterprise/audit/conversation_spec.rb b/spec/models/enterprise/audit/conversation_spec.rb
new file mode 100644
index 000000000..56ea2910d
--- /dev/null
+++ b/spec/models/enterprise/audit/conversation_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe 'Conversation Audit', type: :model do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+
+ before do
+ # Enable auditing for conversations
+ conversation.class.send(:include, Enterprise::Audit::Conversation) if defined?(Enterprise::Audit::Conversation)
+ end
+
+ describe 'audit logging on destroy' do
+ it 'creates an audit log when conversation is destroyed' do
+ skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation)
+
+ expect do
+ conversation.destroy!
+ end.to change(Audited::Audit, :count).by(1)
+
+ audit = Audited::Audit.last
+ expect(audit.auditable_type).to eq('Conversation')
+ expect(audit.action).to eq('destroy')
+ expect(audit.auditable_id).to eq(conversation.id)
+ end
+
+ it 'does not create audit log for other actions by default' do
+ skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation)
+
+ expect do
+ conversation.update!(priority: 'high')
+ end.not_to(change(Audited::Audit, :count))
+ end
+ end
+end
diff --git a/spec/policies/conversation_policy_spec.rb b/spec/policies/conversation_policy_spec.rb
new file mode 100644
index 000000000..ecc3134fc
--- /dev/null
+++ b/spec/policies/conversation_policy_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe ConversationPolicy, type: :policy do
+ subject { described_class }
+
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:administrator_context) { { user: administrator, account: account, account_user: administrator.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } }
+
+ permissions :destroy? do
+ context 'when user is an administrator' do
+ it 'allows destroy' do
+ expect(subject).to permit(administrator_context, conversation)
+ end
+ end
+
+ context 'when user is an agent' do
+ it 'denies destroy' do
+ expect(subject).not_to permit(agent_context, conversation)
+ end
+ end
+ end
+
+ permissions :index? do
+ context 'when user is authenticated' do
+ it 'allows index' do
+ expect(subject).to permit(agent_context, conversation)
+ end
+ end
+ end
+end