Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d27ae25c6 | ||
|
|
270fbe99ea | ||
|
|
c1eb7cda21 | ||
|
|
b4bcfca678 | ||
|
|
ac91e29d42 | ||
|
|
3339b7ecc2 | ||
|
|
0492582546 | ||
|
|
d5c7d4a2b0 | ||
|
|
cce046b419 | ||
|
|
6150b7cc61 | ||
|
|
9faface7e1 | ||
|
|
9ae94d3e50 |
@@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
@conversation.save!
|
@conversation.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @conversation, :destroy?
|
||||||
|
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def permitted_update_params
|
def permitted_update_params
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
|
|||||||
getInboxAssistant(conversationId) {
|
getInboxAssistant(conversationId) {
|
||||||
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete(conversationId) {
|
||||||
|
return axios.delete(`${this.url}/${conversationId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ConversationApi();
|
export default new ConversationApi();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||||
import ChatListHeader from './ChatListHeader.vue';
|
import ChatListHeader from './ChatListHeader.vue';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||||
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
import SaveCustomView from 'next/filter/SaveCustomView.vue';
|
||||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||||
@@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
|
|||||||
const { uiSettings } = useUISettings();
|
const { uiSettings } = useUISettings();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
const conversationListRef = ref(null);
|
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) {
|
async function assignPriority(priority, conversationId = null) {
|
||||||
store.dispatch('setCurrentChatPriority', {
|
store.dispatch('setCurrentChatPriority', {
|
||||||
priority,
|
priority,
|
||||||
@@ -670,26 +696,7 @@ async function markAsUnread(conversationId) {
|
|||||||
await store.dispatch('markMessagesUnread', {
|
await store.dispatch('markMessagesUnread', {
|
||||||
id: conversationId,
|
id: conversationId,
|
||||||
});
|
});
|
||||||
const {
|
redirectToConversationList();
|
||||||
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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
@@ -703,6 +710,7 @@ async function markAsRead(conversationId) {
|
|||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAssignTeam(team, conversationId = null) {
|
async function onAssignTeam(team, conversationId = null) {
|
||||||
try {
|
try {
|
||||||
await store.dispatch('assignTeam', {
|
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('selectConversation', selectConversation);
|
||||||
provide('deSelectConversation', deSelectConversation);
|
provide('deSelectConversation', deSelectConversation);
|
||||||
provide('assignAgent', onAssignAgent);
|
provide('assignAgent', onAssignAgent);
|
||||||
@@ -775,6 +803,7 @@ provide('markAsUnread', markAsUnread);
|
|||||||
provide('markAsRead', markAsRead);
|
provide('markAsRead', markAsRead);
|
||||||
provide('assignPriority', assignPriority);
|
provide('assignPriority', assignPriority);
|
||||||
provide('isConversationSelected', isConversationSelected);
|
provide('isConversationSelected', isConversationSelected);
|
||||||
|
provide('deleteConversation', handleDelete);
|
||||||
|
|
||||||
watch(activeTeam, () => resetAndFetchData());
|
watch(activeTeam, () => resetAndFetchData());
|
||||||
|
|
||||||
@@ -938,6 +967,19 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
</template>
|
</template>
|
||||||
</DynamicScroller>
|
</DynamicScroller>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog
|
||||||
|
ref="deleteConversationDialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="
|
||||||
|
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
|
||||||
|
conversationId: selectedConversationId,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
|
||||||
|
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
|
||||||
|
@confirm="deleteConversation"
|
||||||
|
@close="selectedConversationId = null"
|
||||||
|
/>
|
||||||
<TeleportWithDirection
|
<TeleportWithDirection
|
||||||
v-if="showAdvancedFilters"
|
v-if="showAdvancedFilters"
|
||||||
to="#conversationFilterTeleportTarget"
|
to="#conversationFilterTeleportTarget"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default {
|
|||||||
'markAsRead',
|
'markAsRead',
|
||||||
'assignPriority',
|
'assignPriority',
|
||||||
'isConversationSelected',
|
'isConversationSelected',
|
||||||
|
'deleteConversation',
|
||||||
],
|
],
|
||||||
props: {
|
props: {
|
||||||
source: {
|
source: {
|
||||||
@@ -67,5 +68,6 @@ export default {
|
|||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
@mark-as-read="markAsRead"
|
@mark-as-read="markAsRead"
|
||||||
@assign-priority="assignPriority"
|
@assign-priority="assignPriority"
|
||||||
|
@delete-conversation="deleteConversation"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default {
|
|||||||
'markAsRead',
|
'markAsRead',
|
||||||
'assignPriority',
|
'assignPriority',
|
||||||
'updateConversationStatus',
|
'updateConversationStatus',
|
||||||
|
'deleteConversation',
|
||||||
],
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -237,6 +238,10 @@ export default {
|
|||||||
this.$emit('assignPriority', priority, this.chat.id);
|
this.$emit('assignPriority', priority, this.chat.id);
|
||||||
this.closeContextMenu();
|
this.closeContextMenu();
|
||||||
},
|
},
|
||||||
|
async deleteConversation() {
|
||||||
|
this.$emit('deleteConversation', this.chat.id);
|
||||||
|
this.closeContextMenu();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -363,6 +368,7 @@ export default {
|
|||||||
@mark-as-unread="markAsUnread"
|
@mark-as-unread="markAsUnread"
|
||||||
@mark-as-read="markAsRead"
|
@mark-as-read="markAsRead"
|
||||||
@assign-priority="assignPriority"
|
@assign-priority="assignPriority"
|
||||||
|
@delete-conversation="deleteConversation"
|
||||||
/>
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
|
|||||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||||
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -45,7 +46,14 @@ export default {
|
|||||||
'assignAgent',
|
'assignAgent',
|
||||||
'assignTeam',
|
'assignTeam',
|
||||||
'assignLabel',
|
'assignLabel',
|
||||||
|
'deleteConversation',
|
||||||
],
|
],
|
||||||
|
setup() {
|
||||||
|
const { isAdmin } = useAdmin();
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
};
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||||
@@ -121,6 +129,11 @@ export default {
|
|||||||
icon: 'people-team-add',
|
icon: 'people-team-add',
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
||||||
},
|
},
|
||||||
|
deleteOption: {
|
||||||
|
key: 'delete',
|
||||||
|
icon: 'delete',
|
||||||
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -178,6 +191,9 @@ export default {
|
|||||||
assignPriority(priority) {
|
assignPriority(priority) {
|
||||||
this.$emit('assignPriority', priority);
|
this.$emit('assignPriority', priority);
|
||||||
},
|
},
|
||||||
|
deleteConversation() {
|
||||||
|
this.$emit('deleteConversation', this.chatId);
|
||||||
|
},
|
||||||
show(key) {
|
show(key) {
|
||||||
// If the conversation status is same as the action, then don't display the option
|
// 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.
|
// 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)"
|
@click.stop="$emit('assignTeam', team)"
|
||||||
/>
|
/>
|
||||||
</MenuItemWithSubmenu>
|
</MenuItemWithSubmenu>
|
||||||
|
<template v-if="isAdmin">
|
||||||
|
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||||
|
<MenuItem
|
||||||
|
:option="deleteOption"
|
||||||
|
variant="icon"
|
||||||
|
@click.stop="deleteConversation"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const translationKeys = {
|
|||||||
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
|
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
|
||||||
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
|
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
|
||||||
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
|
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
|
||||||
|
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function extractAttrChange(attrChange) {
|
function extractAttrChange(attrChange) {
|
||||||
@@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
|
|||||||
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
||||||
const action = auditLogItem.action.toLowerCase();
|
const action = auditLogItem.action.toLowerCase();
|
||||||
|
|
||||||
|
if (auditableType === 'conversation' && action === 'destroy') {
|
||||||
|
translationPayload.id =
|
||||||
|
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
|
||||||
|
}
|
||||||
|
|
||||||
if (auditableType === 'accountuser') {
|
if (auditableType === 'accountuser') {
|
||||||
translationPayload = handleAccountUser(
|
translationPayload = handleAccountUser(
|
||||||
auditLogItem,
|
auditLogItem,
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
},
|
},
|
||||||
"ACCOUNT": {
|
"ACCOUNT": {
|
||||||
"EDIT": "{agentName} updated the account configuration (#{id})"
|
"EDIT": "{agentName} updated the account configuration (#{id})"
|
||||||
|
},
|
||||||
|
"CONVERSATION": {
|
||||||
|
"DELETE": "{agentName} deleted conversation #{id}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,11 @@
|
|||||||
"FAILED": "Couldn't change priority. Please try again."
|
"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": {
|
"CARD_CONTEXT_MENU": {
|
||||||
"PENDING": "Mark as pending",
|
"PENDING": "Mark as pending",
|
||||||
"RESOLVED": "Mark as resolved",
|
"RESOLVED": "Mark as resolved",
|
||||||
@@ -134,6 +139,7 @@
|
|||||||
"ASSIGN_LABEL": "Assign label",
|
"ASSIGN_LABEL": "Assign label",
|
||||||
"AGENTS_LOADING": "Loading agents...",
|
"AGENTS_LOADING": "Loading agents...",
|
||||||
"ASSIGN_TEAM": "Assign team",
|
"ASSIGN_TEAM": "Assign team",
|
||||||
|
"DELETE": "Delete conversation",
|
||||||
"API": {
|
"API": {
|
||||||
"AGENT_ASSIGNMENT": {
|
"AGENT_ASSIGNMENT": {
|
||||||
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
|
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
|
||||||
@@ -208,6 +214,8 @@
|
|||||||
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
|
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
|
||||||
"ASSIGN_LABEL_FAILED": "Label assignment failed",
|
"ASSIGN_LABEL_FAILED": "Label assignment failed",
|
||||||
"CHANGE_TEAM": "Conversation team changed",
|
"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",
|
"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",
|
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||||
"SENT_BY": "Sent by:",
|
"SENT_BY": "Sent by:",
|
||||||
|
|||||||
@@ -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) {
|
addConversation({ commit, state, dispatch, rootState }, conversation) {
|
||||||
const { currentInbox, appliedFilters } = state;
|
const { currentInbox, appliedFilters } = state;
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ export const mutations = {
|
|||||||
_state.allConversations.push(conversation);
|
_state.allConversations.push(conversation);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[types.DELETE_CONVERSATION](_state, conversationId) {
|
||||||
|
_state.allConversations = _state.allConversations.filter(
|
||||||
|
c => c.id !== conversationId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION](_state, conversation) {
|
[types.UPDATE_CONVERSATION](_state, conversation) {
|
||||||
const { allConversations } = _state;
|
const { allConversations } = _state;
|
||||||
const index = allConversations.findIndex(c => c.id === conversation.id);
|
const index = allConversations.findIndex(c => c.id === conversation.id);
|
||||||
|
|||||||
@@ -513,6 +513,28 @@ describe('#deleteMessage', () => {
|
|||||||
expect(commit.mock.calls).toEqual([]);
|
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', () => {
|
describe('#updateCustomAttributes', () => {
|
||||||
it('update conversation custom attributes', async () => {
|
it('update conversation custom attributes', async () => {
|
||||||
axios.post.mockResolvedValue({
|
axios.post.mockResolvedValue({
|
||||||
|
|||||||
@@ -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', () => {
|
describe('#SET_LIST_LOADING_STATUS', () => {
|
||||||
it('should set listLoadingStatus to true', () => {
|
it('should set listLoadingStatus to true', () => {
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default {
|
|||||||
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
||||||
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
|
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
|
||||||
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
|
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
|
||||||
|
DELETE_CONVERSATION: 'DELETE_CONVERSATION',
|
||||||
|
|
||||||
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
|
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,8 @@ class Attachment < ApplicationRecord
|
|||||||
|
|
||||||
def push_event_data
|
def push_event_data
|
||||||
return unless file_type
|
return unless file_type
|
||||||
return base_data.merge(location_metadata) if file_type.to_sym == :location
|
|
||||||
return base_data.merge(fallback_data) if file_type.to_sym == :fallback
|
|
||||||
return base_data.merge(contact_metadata) if file_type.to_sym == :contact
|
|
||||||
|
|
||||||
base_data.merge(file_metadata)
|
base_data.merge(metadata_for_file_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
# NOTE: the URl returned does a 301 redirect to the actual file
|
# NOTE: the URl returned does a 301 redirect to the actual file
|
||||||
@@ -76,6 +73,19 @@ class Attachment < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def metadata_for_file_type
|
||||||
|
case file_type.to_sym
|
||||||
|
when :location
|
||||||
|
location_metadata
|
||||||
|
when :fallback
|
||||||
|
fallback_data
|
||||||
|
when :contact
|
||||||
|
contact_metadata
|
||||||
|
else
|
||||||
|
file_metadata
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def file_metadata
|
def file_metadata
|
||||||
metadata = {
|
metadata = {
|
||||||
extension: extension,
|
extension: extension,
|
||||||
|
|||||||
@@ -305,5 +305,6 @@ class Conversation < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Conversation.include_mod_with('Audit::Conversation')
|
||||||
Conversation.include_mod_with('Concerns::Conversation')
|
Conversation.include_mod_with('Concerns::Conversation')
|
||||||
Conversation.prepend_mod_with('Conversation')
|
Conversation.prepend_mod_with('Conversation')
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ class ConversationPolicy < ApplicationPolicy
|
|||||||
def index?
|
def index?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
@account_user&.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ Rails.application.routes.draw do
|
|||||||
namespace :channels do
|
namespace :channels do
|
||||||
resource :twilio_channel, only: [:create]
|
resource :twilio_channel, only: [:create]
|
||||||
end
|
end
|
||||||
resources :conversations, only: [:index, :create, :show, :update] do
|
resources :conversations, only: [:index, :create, :show, :update, :destroy] do
|
||||||
collection do
|
collection do
|
||||||
get :meta
|
get :meta
|
||||||
get :search
|
get :search
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
def message_content(message)
|
def message_content(message)
|
||||||
return message.content if message.content.present?
|
return message.content if message.content.present?
|
||||||
|
|
||||||
return 'User has shared an attachment' if message.attachments.any?
|
message.attachments.any? ? 'User has shared an attachment' : 'User has shared a message without content'
|
||||||
|
|
||||||
'User has shared a message without content'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def determine_role(message)
|
def determine_role(message)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Enterprise::DeleteObjectJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_audit_entry(object, user, ip)
|
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(
|
Enterprise::AuditLog.create(
|
||||||
auditable: object,
|
auditable: object,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module Enterprise::Audit::Conversation
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
audited only: [], on: [:destroy]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -926,4 +926,63 @@ RSpec.describe 'Conversations API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user