From 273c277d478135ab4eff7a6b81e4699553c3c245 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 5 Jun 2025 15:53:17 -0500 Subject: [PATCH] feat: Add conversation delete feature (#11677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-06-05 at 12 39 04 AM ## Changes Give the admins an option to delete conversation via the context menu - enable conversation deletion in routes and controller - expose delete API on conversations - add delete option in conversation context menu and integrate with card and list - implement store action and mutation for delete - update i18n with new strings fixes: https://github.com/chatwoot/chatwoot/issues/947 --------- Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Pranav --- .../v1/accounts/conversations_controller.rb | 6 ++ .../dashboard/api/inbox/conversation.js | 4 + .../dashboard/components/ChatList.vue | 82 ++++++++++++++----- .../dashboard/components/ConversationItem.vue | 2 + .../widgets/conversation/ConversationCard.vue | 6 ++ .../conversation/contextMenu/Index.vue | 24 ++++++ .../dashboard/helper/auditlogHelper.js | 6 ++ .../dashboard/i18n/locale/en/auditLogs.json | 3 + .../i18n/locale/en/conversation.json | 8 ++ .../store/modules/conversations/actions.js | 10 +++ .../store/modules/conversations/index.js | 6 ++ .../specs/conversations/actions.spec.js | 22 +++++ .../specs/conversations/mutations.spec.js | 11 +++ .../dashboard/store/mutation-types.js | 1 + app/models/conversation.rb | 1 + app/policies/conversation_policy.rb | 4 + config/routes.rb | 2 +- .../app/jobs/enterprise/delete_object_job.rb | 2 +- .../models/enterprise/audit/conversation.rb | 7 ++ .../accounts/conversations_controller_spec.rb | 59 +++++++++++++ .../enterprise/audit/conversation_spec.rb | 34 ++++++++ spec/policies/conversation_policy_spec.rb | 34 ++++++++ 22 files changed, 312 insertions(+), 22 deletions(-) create mode 100644 enterprise/app/models/enterprise/audit/conversation.rb create mode 100644 spec/models/enterprise/audit/conversation_spec.rb create mode 100644 spec/policies/conversation_policy_spec.rb 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