Compare commits
29
Commits
zoom
...
fix-Agent-Email
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d49d499e4 | ||
|
|
29503af824 | ||
|
|
75cc9356a4 | ||
|
|
0e47a14350 | ||
|
|
5c30cc2da3 | ||
|
|
e93af2681e | ||
|
|
e1e8857195 | ||
|
|
b5c6d90abd | ||
|
|
fd832d7593 | ||
|
|
29e44ac6d0 | ||
|
|
58ee2e125a | ||
|
|
89d0b2cb6e | ||
|
|
476077ab84 | ||
|
|
586552013e | ||
|
|
3dae3ff3ad | ||
|
|
29171565ed | ||
|
|
55fcbe2dde | ||
|
|
7f4b2d66d4 | ||
|
|
804a42c271 | ||
|
|
561fafa198 | ||
|
|
aaf70cf1cf | ||
|
|
cb2b75ca17 | ||
|
|
608592db0c | ||
|
|
ab1f803354 | ||
|
|
b81c722ac8 | ||
|
|
b40ad399f6 | ||
|
|
dcd18072b4 | ||
|
|
bda2d1d9a4 | ||
|
|
83231b9678 |
@@ -16,7 +16,6 @@ class AgentBuilder
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
send_confirmation_if_required
|
||||
create_account_user
|
||||
end
|
||||
@user
|
||||
@@ -34,11 +33,6 @@ class AgentBuilder
|
||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
||||
end
|
||||
|
||||
# Sends confirmation instructions if the user is persisted and not confirmed.
|
||||
def send_confirmation_if_required
|
||||
@user.send_confirmation_instructions if user_needs_confirmation?
|
||||
end
|
||||
|
||||
# Checks if the user needs confirmation.
|
||||
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
|
||||
def user_needs_confirmation?
|
||||
|
||||
@@ -36,6 +36,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@conversation.update!(permitted_update_params)
|
||||
end
|
||||
|
||||
def filter
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
@conversations = result[:conversations]
|
||||
@@ -110,6 +114,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
private
|
||||
|
||||
def permitted_update_params
|
||||
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
|
||||
params.permit(:priority)
|
||||
end
|
||||
|
||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||
@@ -176,3 +185,5 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversation.assignee_id? && Current.user == @conversation.assignee
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
|
||||
@@ -84,6 +84,24 @@ class ReportsAPI extends ApiClient {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getBotMetrics({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/bot_metrics`, {
|
||||
params: { since: from, until: to },
|
||||
});
|
||||
}
|
||||
|
||||
getBotSummary({ from, to, groupBy, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/bot_summary`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
||||
@@ -111,6 +111,40 @@ describe('#Reports API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('#getBotMetrics', () => {
|
||||
reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/bot_metrics',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getBotSummary', () => {
|
||||
reportsAPI.getBotSummary({
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
groupBy: 'date',
|
||||
businessHours: true,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/bot_summary',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
group_by: 'date',
|
||||
business_hours: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getConversationMetric', () => {
|
||||
reportsAPI.getConversationMetric('account');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const reports = accountId => ({
|
||||
@@ -6,6 +7,7 @@ const reports = accountId => ({
|
||||
'account_overview_reports',
|
||||
'conversation_reports',
|
||||
'csat_reports',
|
||||
'bot_reports',
|
||||
'agent_reports',
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
@@ -33,6 +35,14 @@ const reports = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/reports/csat`),
|
||||
toStateName: 'csat_reports',
|
||||
},
|
||||
{
|
||||
icon: 'bot',
|
||||
label: 'REPORTS_BOT',
|
||||
hasSubMenu: false,
|
||||
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/bot`),
|
||||
toStateName: 'bot_reports',
|
||||
},
|
||||
{
|
||||
icon: 'people',
|
||||
label: 'REPORTS_AGENT',
|
||||
|
||||
@@ -160,7 +160,7 @@ const settings = accountId => ({
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
icon: 'key',
|
||||
icon: 'document-list-clock',
|
||||
label: 'SLA',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mapUrl = computed(
|
||||
() => `https://maps.google.com/?q=${props.latitude},${props.longitude}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="location message-text__wrap">
|
||||
<div class="icon-wrap">
|
||||
<fluent-icon icon="location" class="file--icon" size="32" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div
|
||||
class="flex flex-row items-center justify-start gap-1 w-full py-1 px-0 cursor-pointer overflow-hidden"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="location"
|
||||
class="text-slate-600 dark:text-slate-200 leading-none my-0 flex items-center flex-shrink-0"
|
||||
size="32"
|
||||
/>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<h5
|
||||
class="text-sm text-slate-800 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
class="text-sm text-slate-800 dark:text-slate-100 truncate m-0 w-full"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</h5>
|
||||
<div class="link-wrap">
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
class="download clear link button small"
|
||||
class="text-woot-600 dark:text-woot-600 text-xs underline"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:href="mapUrl"
|
||||
@@ -22,49 +50,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
mapUrl() {
|
||||
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.location {
|
||||
@apply flex flex-row py-1 px-0 cursor-pointer;
|
||||
|
||||
.icon-wrap {
|
||||
@apply text-slate-600 dark:text-slate-200 leading-none my-0 mx-1;
|
||||
}
|
||||
|
||||
.text-block-title {
|
||||
@apply m-0 text-slate-800 dark:text-slate-100 break-words;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@apply flex flex-col items-center pr-4;
|
||||
}
|
||||
|
||||
.link-wrap {
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
}"
|
||||
>
|
||||
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
|
||||
<letter
|
||||
v-else
|
||||
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
|
||||
:html="message"
|
||||
/>
|
||||
<div v-else @click="handleClickOnContent">
|
||||
<letter
|
||||
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
|
||||
:html="message"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="showQuoteToggle"
|
||||
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
|
||||
@@ -26,14 +27,23 @@
|
||||
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
|
||||
</span>
|
||||
</button>
|
||||
<gallery-view
|
||||
v-if="showGalleryViewer"
|
||||
:show.sync="showGalleryViewer"
|
||||
:attachment="attachment"
|
||||
:all-attachments="availableAttachments"
|
||||
@error="onClose"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Letter from 'vue-letter';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
|
||||
export default {
|
||||
components: { Letter },
|
||||
components: { Letter, GalleryView },
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
@@ -51,6 +61,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showQuotedContent: false,
|
||||
showGalleryViewer: false,
|
||||
attachment: {},
|
||||
availableAttachments: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -71,6 +84,33 @@ export default {
|
||||
toggleQuotedContent() {
|
||||
this.showQuotedContent = !this.showQuotedContent;
|
||||
},
|
||||
handleClickOnContent(event) {
|
||||
// if event target is IMG and not close in A tag
|
||||
// then open image preview
|
||||
const isImageElement = event.target.tagName === 'IMG';
|
||||
const isWrappedInLink = event.target.closest('A');
|
||||
|
||||
if (isImageElement && !isWrappedInLink) {
|
||||
this.openImagePreview(event.target.src);
|
||||
}
|
||||
},
|
||||
openImagePreview(src) {
|
||||
this.showGalleryViewer = true;
|
||||
this.attachment = {
|
||||
file_type: 'image',
|
||||
data_url: src,
|
||||
message_id: Math.floor(Math.random() * 100),
|
||||
};
|
||||
this.availableAttachments = [{ ...this.attachment }];
|
||||
},
|
||||
onClose() {
|
||||
this.showGalleryViewer = false;
|
||||
this.resetAttachmentData();
|
||||
},
|
||||
resetAttachmentData() {
|
||||
this.attachment = {};
|
||||
this.availableAttachments = [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -82,6 +122,7 @@ export default {
|
||||
ol {
|
||||
padding-left: var(--space-two);
|
||||
}
|
||||
|
||||
table {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
|
||||
+84
-26
@@ -8,45 +8,66 @@
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit]"
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
||||
@click="onClose"
|
||||
>
|
||||
<div class="items-center flex h-16 justify-between py-2 px-6 w-full">
|
||||
<div class="items-center flex justify-start min-w-[15rem]" @click.stop>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 z-10 flex items-center justify-between w-full h-16 px-6 py-2"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="senderDetails"
|
||||
class="items-center flex justify-start min-w-[15rem]"
|
||||
>
|
||||
<thumbnail
|
||||
v-if="senderDetails.avatar"
|
||||
:username="senderDetails.name"
|
||||
:src="senderDetails.avatar"
|
||||
/>
|
||||
<div
|
||||
class="flex items-start flex-col justify-center ml-2 rtl:ml-0 rtl:mr-2"
|
||||
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
|
||||
>
|
||||
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
|
||||
<span
|
||||
class="text-slate-800 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
class="overflow-hidden text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ senderDetails.name }}
|
||||
</span>
|
||||
</h3>
|
||||
<span
|
||||
class="text-xs m-0 p-0 text-slate-400 dark:text-slate-200 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
class="p-0 m-0 overflow-hidden text-xs text-slate-400 dark:text-slate-200 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ readableTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center text-slate-700 dark:text-slate-100 flex font-semibold justify-start min-w-0 p-1 w-auto text-sm"
|
||||
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<span
|
||||
class="text-slate-700 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ fileNameFromDataUrl }}
|
||||
</span>
|
||||
v-dompurify-html="fileNameFromDataUrl"
|
||||
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
|
||||
@click.stop
|
||||
>
|
||||
<woot-button
|
||||
v-if="isImage"
|
||||
size="large"
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
icon="zoom-in"
|
||||
@click="onZoom(0.1)"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="isImage"
|
||||
size="large"
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
icon="zoom-out"
|
||||
@click="onZoom(-0.1)"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="isImage"
|
||||
size="large"
|
||||
@@ -79,10 +100,11 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-center flex h-full justify-center w-full">
|
||||
<div class="flex items-center justify-center w-full h-full">
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<woot-button
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
class="z-10"
|
||||
size="large"
|
||||
variant="smooth"
|
||||
color-scheme="primary"
|
||||
@@ -96,15 +118,16 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center flex-col justify-center w-full h-full">
|
||||
<div class="flex flex-col items-center justify-center w-full h-full">
|
||||
<div>
|
||||
<img
|
||||
v-if="isImage"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
class="modal-image skip-context-menu my-0 mx-auto"
|
||||
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
|
||||
:style="imageRotationStyle"
|
||||
@click.stop
|
||||
@click.stop="onClickZoomImage"
|
||||
@wheel.stop="onWheelImageZoom"
|
||||
/>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
@@ -112,7 +135,7 @@
|
||||
:src="activeAttachment.data_url"
|
||||
controls
|
||||
playsInline
|
||||
class="modal-video skip-context-menu my-0 mx-auto"
|
||||
class="mx-auto my-0 modal-video skip-context-menu"
|
||||
@click.stop
|
||||
/>
|
||||
<audio
|
||||
@@ -129,6 +152,7 @@
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<woot-button
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
class="z-10"
|
||||
size="large"
|
||||
variant="smooth"
|
||||
color-scheme="primary"
|
||||
@@ -143,7 +167,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-center flex h-16 justify-center w-full py-2 px-6">
|
||||
<div class="flex items-center justify-center w-full h-16 px-6 py-2 z-10">
|
||||
<div
|
||||
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
|
||||
>
|
||||
@@ -174,6 +198,9 @@ const ALLOWED_FILE_TYPES = {
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
const MAX_ZOOM_LEVEL = 2;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
@@ -195,6 +222,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
zoomScale: 1,
|
||||
activeAttachment: {},
|
||||
activeFileType: '',
|
||||
activeImageIndex:
|
||||
@@ -212,12 +240,9 @@ export default {
|
||||
return this.allAttachments.length > 1;
|
||||
},
|
||||
readableTime() {
|
||||
if (!this.activeAttachment.created_at) return '';
|
||||
const time = this.messageTimestamp(
|
||||
this.activeAttachment.created_at,
|
||||
'LLL d yyyy, h:mm a'
|
||||
);
|
||||
return time || '';
|
||||
const { created_at: createdAt } = this.activeAttachment;
|
||||
if (!createdAt) return '';
|
||||
return this.messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
|
||||
},
|
||||
isImage() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
|
||||
@@ -246,11 +271,12 @@ export default {
|
||||
const { data_url: dataUrl } = this.activeAttachment;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return fileName || '';
|
||||
return decodeURIComponent(fileName || '');
|
||||
},
|
||||
imageRotationStyle() {
|
||||
return {
|
||||
transform: `rotate(${this.activeImageRotation}deg)`,
|
||||
transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`,
|
||||
cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -268,6 +294,7 @@ export default {
|
||||
this.activeImageIndex = index;
|
||||
this.setImageAndVideoSrc(attachment);
|
||||
this.activeImageRotation = 0;
|
||||
this.zoomScale = 1;
|
||||
},
|
||||
setImageAndVideoSrc(attachment) {
|
||||
const { file_type: type } = attachment;
|
||||
@@ -316,6 +343,37 @@ export default {
|
||||
this.activeImageRotation += rotation;
|
||||
}
|
||||
},
|
||||
onClickZoomImage() {
|
||||
this.onZoom(0.1);
|
||||
},
|
||||
onZoom(scale) {
|
||||
if (!this.isImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newZoomScale = this.zoomScale + scale;
|
||||
// Check if the new zoom scale is within the allowed range
|
||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
||||
// Set zoom to max but do not reset to default
|
||||
this.zoomScale = MAX_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
||||
// Set zoom to min but do not reset to default
|
||||
this.zoomScale = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// If within bounds, update the zoom scale
|
||||
this.zoomScale = newZoomScale;
|
||||
},
|
||||
|
||||
onWheelImageZoom(e) {
|
||||
if (!this.isImage) {
|
||||
return;
|
||||
}
|
||||
const scale = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
this.onZoom(scale);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -19,4 +19,5 @@ export const FEATURE_FLAGS = {
|
||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
};
|
||||
|
||||
@@ -87,7 +87,10 @@
|
||||
"conversation_assignment": "Conversation Assigned",
|
||||
"assigned_conversation_new_message": "New Message",
|
||||
"participating_conversation_new_message": "New Message",
|
||||
"conversation_mention": "Mention"
|
||||
"conversation_mention": "Mention",
|
||||
"sla_missed_first_response": "SLA Missed",
|
||||
"sla_missed_next_response": "SLA Missed",
|
||||
"sla_missed_resolution": "SLA Missed"
|
||||
}
|
||||
},
|
||||
"NETWORK": {
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"CONVERSATION_CREATION": "New conversation created",
|
||||
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
|
||||
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in"
|
||||
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in",
|
||||
"SLA_MISSED_FIRST_RESPONSE": "SLA target first response missed for conversation",
|
||||
"SLA_MISSED_NEXT_RESPONSE": "SLA target next response missed for conversation",
|
||||
"SLA_MISSED_RESOLUTION": "SLA target resolution missed for conversation"
|
||||
},
|
||||
"MENU_ITEM": {
|
||||
"MARK_AS_READ": "Mark as read",
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Handoff Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"NAME": "Customer waiting time",
|
||||
"TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} replies)"
|
||||
@@ -86,20 +94,49 @@
|
||||
"MONTH": "Month",
|
||||
"YEAR": "Year"
|
||||
},
|
||||
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
|
||||
"GROUP_BY_DAY_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_WEEK_OPTIONS": [
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
{ "id": 2, "groupBy": "Week" }
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_MONTH_OPTIONS": [
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" }
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_YEAR_OPTIONS": [
|
||||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" },
|
||||
{ "id": 4, "groupBy": "Year" }
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"groupBy": "Year"
|
||||
}
|
||||
],
|
||||
"BUSINESS_HOURS": "Business Hours"
|
||||
},
|
||||
@@ -404,6 +441,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BOT_REPORTS": {
|
||||
"HEADER": "Bot Reports",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "No. of Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Total Responses",
|
||||
"TOOLTIP": "Total number of responses sent by the bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolution Rate",
|
||||
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Handoff Rate",
|
||||
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OVERVIEW_REPORTS": {
|
||||
"HEADER": "Overview",
|
||||
"LIVE": "Live",
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
|
||||
@@ -55,11 +55,17 @@
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit SLA",
|
||||
"DELETE": {
|
||||
"TITLE": "Delete SLA",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "SLA updated successfully",
|
||||
"SUCCESS_MESSAGE": "SLA deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"MESSAGE": "Are you sure you want to delete ",
|
||||
"YES": "Yes, Delete ",
|
||||
"NO": "No, Keep "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ import { mapGetters } from 'vuex';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
accountSummaryKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountSummary: 'getAccountSummary',
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
accountSummary() {
|
||||
return this.$store.getters[this.accountSummaryKey];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
calculateTrend(key) {
|
||||
|
||||
@@ -11,11 +11,42 @@ describe('reportMixin', () => {
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
getAccountSummary: () => reportFixtures.summary,
|
||||
getBotSummary: () => reportFixtures.botSummary,
|
||||
getAccountReports: () => reportFixtures.report,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
||||
it('display the metric for account', async () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
await wrapper.setProps({
|
||||
accountSummaryKey: 'getAccountSummary',
|
||||
});
|
||||
expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
|
||||
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
|
||||
'3 Min 18 Sec'
|
||||
);
|
||||
});
|
||||
|
||||
it('display the metric for bot', async () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
await wrapper.setProps({
|
||||
accountSummaryKey: 'getBotSummary',
|
||||
});
|
||||
expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10');
|
||||
expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20');
|
||||
});
|
||||
|
||||
it('display the metric', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
||||
@@ -15,6 +15,14 @@ export default {
|
||||
},
|
||||
resolutions_count: 3,
|
||||
},
|
||||
botSummary: {
|
||||
bot_resolutions_count: 10,
|
||||
bot_handoffs_count: 20,
|
||||
previous: {
|
||||
bot_resolutions_count: 8,
|
||||
bot_handoffs_count: 5,
|
||||
},
|
||||
},
|
||||
report: {
|
||||
data: [
|
||||
{ value: '0.00', timestamp: 1647541800, count: 0 },
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin],
|
||||
@@ -125,10 +126,9 @@ export default {
|
||||
});
|
||||
this.errorMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
|
||||
} catch (error) {
|
||||
this.errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
|
||||
if (error?.response?.data?.message) {
|
||||
this.errorMessage = error.response.data.message;
|
||||
}
|
||||
this.errorMessage =
|
||||
parseAPIErrorResponse(error) ||
|
||||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
|
||||
} finally {
|
||||
this.isPasswordChanging = false;
|
||||
this.showAlert(this.errorMessage);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<report-filter-selector
|
||||
:show-agents-filter="false"
|
||||
:show-group-by-filter="true"
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<bot-metrics :filters="requestPayload" />
|
||||
<report-container
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:account-summary-key="'getBotSummary'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import BotMetrics from './components/BotMetrics.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import reportMixin from 'dashboard/mixins/reportMixin';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'BotReports',
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
},
|
||||
mixins: [reportMixin],
|
||||
data() {
|
||||
return {
|
||||
from: 0,
|
||||
to: 0,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
reportKeys: {
|
||||
BOT_RESOLUTION_COUNT: 'bot_resolutions_count',
|
||||
BOT_HANDOFF_COUNT: 'bot_handoffs_count',
|
||||
},
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
requestPayload() {
|
||||
return {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchAllData() {
|
||||
this.fetchBotSummary();
|
||||
this.fetchChartData();
|
||||
},
|
||||
fetchBotSummary() {
|
||||
try {
|
||||
this.$store.dispatch('fetchBotSummary', this.getRequestPayload());
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
Object.keys(this.reportKeys).forEach(async key => {
|
||||
try {
|
||||
await this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.reportKeys[key],
|
||||
...this.getRequestPayload(),
|
||||
});
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
|
||||
}
|
||||
});
|
||||
},
|
||||
getRequestPayload() {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy?.period,
|
||||
businessHours,
|
||||
};
|
||||
},
|
||||
onFilterChange({ from, to, groupBy, businessHours }) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.groupBy = groupBy;
|
||||
this.businessHours = businessHours;
|
||||
this.fetchAllData();
|
||||
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterValue: { from, to, groupBy, businessHours },
|
||||
reportType: 'bots',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
:key="metric.KEY"
|
||||
class="p-4 rounded-md mb-3"
|
||||
>
|
||||
<chart-stats :metric="metric" />
|
||||
<chart-stats :metric="metric" :account-summary-key="accountSummaryKey" />
|
||||
<div class="mt-4 h-72">
|
||||
<woot-loading-state
|
||||
v-if="accountReport.isFetching[metric.KEY]"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import ReportMetricCard from './ReportMetricCard.vue';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const conversationCount = ref('0');
|
||||
const messageCount = ref('0');
|
||||
const resolutionRate = ref('0');
|
||||
const handoffRate = ref('0');
|
||||
|
||||
const formatToPercent = value => {
|
||||
return value ? `${value}%` : '--';
|
||||
};
|
||||
|
||||
const fetchMetrics = () => {
|
||||
if (!props.filters.to || !props.filters.from) {
|
||||
return;
|
||||
}
|
||||
ReportsAPI.getBotMetrics(props.filters).then(response => {
|
||||
conversationCount.value = response.data.conversation_count.toLocaleString();
|
||||
messageCount.value = response.data.message_count.toLocaleString();
|
||||
resolutionRate.value = response.data.resolution_rate.toString();
|
||||
handoffRate.value = response.data.handoff_rate.toString();
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.filters, fetchMetrics, { deep: true });
|
||||
|
||||
onMounted(fetchMetrics);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||
>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
|
||||
:value="conversationCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="messageCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(resolutionRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(handoffRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="text-sm">{{ metric.NAME }}</span>
|
||||
<div class="text-slate-900 dark:text-slate-100">
|
||||
<span class="text-sm">
|
||||
{{ metric.NAME }}
|
||||
</span>
|
||||
<div class="flex items-end">
|
||||
<div class="font-medium text-xl">
|
||||
{{ displayMetric(metric.KEY) }}
|
||||
|
||||
@@ -6,17 +6,20 @@
|
||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="responseCount"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:disabled="ratingFilterEnabled"
|
||||
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
|
||||
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(responseRate)"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ defineProps({
|
||||
<template>
|
||||
<div
|
||||
ref="reportMetricContainer"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%] m-0 p-4"
|
||||
class="m-0 p-4"
|
||||
:class="{
|
||||
'grayscale pointer-events-none opacity-30': disabled,
|
||||
}"
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
|
||||
exports[`CsatMetrics.vue computes response count correctly 1`] = `
|
||||
<div class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700">
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"></csat-metric-card-stub>
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"></csat-metric-card-stub>
|
||||
<!---->
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -194,6 +194,8 @@ export const METRIC_CHART = {
|
||||
reply_time: TIME_CHART_CONFIG,
|
||||
avg_resolution_time: TIME_CHART_CONFIG,
|
||||
resolutions_count: DEFAULT_CHART,
|
||||
bot_resolutions_count: DEFAULT_CHART,
|
||||
bot_handoffs_count: DEFAULT_CHART,
|
||||
};
|
||||
|
||||
export const OVERVIEW_METRICS = {
|
||||
|
||||
@@ -7,6 +7,7 @@ const LabelReports = () => import('./LabelReports.vue');
|
||||
const InboxReports = () => import('./InboxReports.vue');
|
||||
const TeamReports = () => import('./TeamReports.vue');
|
||||
const CsatResponses = () => import('./CsatResponses.vue');
|
||||
const BotReports = () => import('./BotReports.vue');
|
||||
const LiveReports = () => import('./LiveReports.vue');
|
||||
|
||||
export default {
|
||||
@@ -66,6 +67,23 @@ export default {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'BOT_REPORTS.HEADER',
|
||||
icon: 'bot',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'bot',
|
||||
name: 'bot_reports',
|
||||
roles: ['administrator'],
|
||||
component: BotReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header :header-title="pageTitle" />
|
||||
<sla-form
|
||||
:submit-label="$t('SLA.FORM.EDIT')"
|
||||
:selected-response="selectedResponse"
|
||||
@submit="editSLA"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import validationMixin from './validationMixin';
|
||||
import validations from './validations';
|
||||
import SlaForm from './SlaForm.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SlaForm,
|
||||
},
|
||||
mixins: [alertMixin, validationMixin],
|
||||
props: {
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
validations,
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
pageTitle() {
|
||||
return `${this.$t('SLA.EDIT.TITLE')} - ${this.selectedResponse.name}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async editSLA(payload) {
|
||||
try {
|
||||
await this.$store.dispatch('sla/update', {
|
||||
id: this.selectedResponse.id,
|
||||
...payload,
|
||||
});
|
||||
this.showAlert(this.$t('SLA.EDIT.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || this.$t('SLA.EDIT.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -58,14 +58,14 @@
|
||||
</td>
|
||||
<td class="button-wrapper">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('SLA.FORM.EDIT')"
|
||||
v-tooltip.top="$t('SLA.FORM.DELETE')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss-circle"
|
||||
class-names="grey-btn"
|
||||
:is-loading="loading[sla.id]"
|
||||
icon="edit"
|
||||
@click="openEditPopup(sla)"
|
||||
@click="openDeletePopup(sla)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -81,9 +81,16 @@
|
||||
<add-SLA @close="hideAddPopup" />
|
||||
</woot-modal>
|
||||
|
||||
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
|
||||
<edit-SLA :selected-response="selectedResponse" @close="hideEditPopup" />
|
||||
</woot-modal>
|
||||
<woot-delete-modal
|
||||
:show.sync="showDeleteConfirmationPopup"
|
||||
:on-close="closeDeletePopup"
|
||||
:on-confirm="confirmDeletion"
|
||||
:title="$t('SLA.DELETE.CONFIRM.TITLE')"
|
||||
:message="$t('SLA.DELETE.CONFIRM.MESSAGE')"
|
||||
:message-value="deleteMessage"
|
||||
:confirm-text="deleteConfirmText"
|
||||
:reject-text="deleteRejectText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
@@ -91,20 +98,18 @@ import { mapGetters } from 'vuex';
|
||||
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
|
||||
|
||||
import AddSLA from './AddSLA.vue';
|
||||
import EditSLA from './EditSLA.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddSLA,
|
||||
EditSLA,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
showAddPopup: false,
|
||||
showEditPopup: false,
|
||||
showDeleteConfirmationPopup: false,
|
||||
selectedResponse: {},
|
||||
};
|
||||
},
|
||||
@@ -113,6 +118,15 @@ export default {
|
||||
records: 'sla/getSLA',
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
deleteConfirmText() {
|
||||
return this.$t('SLA.DELETE.CONFIRM.YES');
|
||||
},
|
||||
deleteRejectText() {
|
||||
return this.$t('SLA.DELETE.CONFIRM.NO');
|
||||
},
|
||||
deleteMessage() {
|
||||
return ` ${this.selectedResponse.name}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('sla/get');
|
||||
@@ -124,12 +138,30 @@ export default {
|
||||
hideAddPopup() {
|
||||
this.showAddPopup = false;
|
||||
},
|
||||
openEditPopup(response) {
|
||||
this.showEditPopup = true;
|
||||
openDeletePopup(response) {
|
||||
this.showDeleteConfirmationPopup = true;
|
||||
this.selectedResponse = response;
|
||||
},
|
||||
hideEditPopup() {
|
||||
this.showEditPopup = false;
|
||||
closeDeletePopup() {
|
||||
this.showDeleteConfirmationPopup = false;
|
||||
},
|
||||
confirmDeletion() {
|
||||
this.loading[this.selectedResponse.id] = true;
|
||||
this.closeDeletePopup();
|
||||
this.deleteSla(this.selectedResponse.id);
|
||||
},
|
||||
deleteSla(id) {
|
||||
this.$store
|
||||
.dispatch('sla/delete', id)
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('SLA.DELETE.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(() => {
|
||||
this.showAlert(this.$t('SLA.DELETE.API.ERROR_MESSAGE'));
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading[this.selectedResponse.id] = false;
|
||||
});
|
||||
},
|
||||
displayTime(threshold) {
|
||||
const { time, unit } = convertSecondsToTimeUnit(threshold, {
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'SLA.HEADER',
|
||||
icon: 'tag',
|
||||
icon: 'document-list-clock',
|
||||
showNewButton: true,
|
||||
},
|
||||
children: [
|
||||
|
||||
@@ -19,6 +19,8 @@ const state = {
|
||||
avg_first_response_time: false,
|
||||
avg_resolution_time: false,
|
||||
resolutions_count: false,
|
||||
bot_resolutions_count: false,
|
||||
bot_handoffs_count: false,
|
||||
reply_time: false,
|
||||
},
|
||||
data: {
|
||||
@@ -28,6 +30,8 @@ const state = {
|
||||
avg_first_response_time: [],
|
||||
avg_resolution_time: [],
|
||||
resolutions_count: [],
|
||||
bot_resolutions_count: [],
|
||||
bot_handoffs_count: [],
|
||||
reply_time: [],
|
||||
},
|
||||
},
|
||||
@@ -39,6 +43,13 @@ const state = {
|
||||
outgoing_messages_count: 0,
|
||||
reply_time: 0,
|
||||
resolutions_count: 0,
|
||||
bot_resolutions_count: 0,
|
||||
bot_handoffs_count: 0,
|
||||
previous: {},
|
||||
},
|
||||
botSummary: {
|
||||
bot_resolutions_count: 0,
|
||||
bot_handoffs_count: 0,
|
||||
previous: {},
|
||||
},
|
||||
overview: {
|
||||
@@ -60,6 +71,9 @@ const getters = {
|
||||
getAccountSummary(_state) {
|
||||
return _state.accountSummary;
|
||||
},
|
||||
getBotSummary(_state) {
|
||||
return _state.botSummary;
|
||||
},
|
||||
getAccountConversationMetric(_state) {
|
||||
return _state.overview.accountConversationMetric;
|
||||
},
|
||||
@@ -125,6 +139,20 @@ export const actions = {
|
||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||
});
|
||||
},
|
||||
fetchBotSummary({ commit }, reportObj) {
|
||||
Report.getBotSummary({
|
||||
from: reportObj.from,
|
||||
to: reportObj.to,
|
||||
groupBy: reportObj.groupBy,
|
||||
businessHours: reportObj.businessHours,
|
||||
})
|
||||
.then(botSummary => {
|
||||
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||
});
|
||||
},
|
||||
fetchAccountConversationMetric({ commit }, reportObj) {
|
||||
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
|
||||
Report.getConversationMetric(reportObj.type)
|
||||
@@ -243,6 +271,9 @@ const mutations = {
|
||||
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
||||
_state.accountSummary = summaryData;
|
||||
},
|
||||
[types.default.SET_BOT_SUMMARY](_state, summaryData) {
|
||||
_state.botSummary = summaryData;
|
||||
},
|
||||
[types.default.SET_ACCOUNT_CONVERSATION_METRIC](_state, metricData) {
|
||||
_state.overview.accountConversationMetric = metricData;
|
||||
},
|
||||
|
||||
@@ -50,16 +50,16 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
update: async function update({ commit }, { id, ...updateObj }) {
|
||||
commit(types.SET_SLA_UI_FLAG, { isUpdating: true });
|
||||
delete: async function deleteSla({ commit }, id) {
|
||||
commit(types.SET_SLA_UI_FLAG, { isDeleting: true });
|
||||
try {
|
||||
const response = await SlaAPI.update(id, updateObj);
|
||||
AnalyticsHelper.track(SLA_EVENTS.UPDATE);
|
||||
commit(types.EDIT_SLA, response.data.payload);
|
||||
await SlaAPI.delete(id);
|
||||
AnalyticsHelper.track(SLA_EVENTS.DELETED);
|
||||
commit(types.DELETE_SLA, id);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.SET_SLA_UI_FLAG, { isUpdating: false });
|
||||
commit(types.SET_SLA_UI_FLAG, { isDeleting: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const mutations = {
|
||||
|
||||
[types.SET_SLA]: MutationHelpers.set,
|
||||
[types.ADD_SLA]: MutationHelpers.create,
|
||||
[types.EDIT_SLA]: MutationHelpers.update,
|
||||
[types.DELETE_SLA]: MutationHelpers.destroy,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -166,6 +166,7 @@ export default {
|
||||
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
|
||||
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
|
||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
||||
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
||||
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
|
||||
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"document-outline": "M18.5 20a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v10Zm-5-15.379L17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621Zm5.914 3.793-5.829-5.828c-.026-.026-.058-.046-.085-.07a2.072 2.072 0 0 0-.219-.18c-.04-.027-.086-.045-.128-.068-.071-.04-.141-.084-.216-.116a1.977 1.977 0 0 0-.624-.138C12.266 2.011 12.22 2 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9.828a2 2 0 0 0-.586-1.414Z",
|
||||
"document-error-outline": "M6 2a2 2 0 0 0-2 2v5.207a5.48 5.48 0 0 1 1-.185V4a1 1 0 0 1 1-1h4v3.5A1.5 1.5 0 0 0 11.5 8H15v8a1 1 0 0 1-1 1h-3.6a5.507 5.507 0 0 1-.657 1H14a2 2 0 0 0 2-2V7.414a1.5 1.5 0 0 0-.44-1.06l-3.914-3.915A1.5 1.5 0 0 0 10.586 2H6Zm8.793 5H11.5a.5.5 0 0 1-.5-.5V3.207L14.793 7ZM10 14.5a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0ZM5.5 12a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 1 0v-2a.5.5 0 0 0-.5-.5Zm0 5.125a.625.625 0 1 0 0-1.25a.625.625 0 0 0 0 1.25Z",
|
||||
"document-text-link-outline": "M18 20.5a.5.5 0 0 0 .5-.5V10H14a2 2 0 0 1-2-2V3.5H6a.5.5 0 0 0-.5.5v10h-.75c-.255 0-.506.02-.75.059V4a2 2 0 0 1 2-2h6.172c.028 0 .055.004.082.007.02.003.04.006.059.007.215.015.427.056.624.138.057.024.112.056.166.087l.05.029.047.024a.652.652 0 0 1 .081.044c.078.053.148.116.219.18a.63.63 0 0 0 .036.03.491.491 0 0 1 .049.04l5.829 5.828A2 2 0 0 1 20 9.828V20a2 2 0 0 1-2 2h-6.286c.406-.432.731-.94.953-1.5H18Zm-.622-12L13.5 4.621V8a.5.5 0 0 0 .5.5h3.378Zm-7.603 5.75c.854.29 1.6.815 2.158 1.5h3.317a.75.75 0 0 0 0-1.5H9.775ZM12.667 17c.186.468.3.973.326 1.5h2.257a.75.75 0 0 0 0-1.5h-2.583ZM8.75 11.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5ZM12 18.75A3.75 3.75 0 0 0 8.25 15l-.102.007A.75.75 0 0 0 8.25 16.5l.154.005A2.25 2.25 0 0 1 8.25 21l-.003.005-.102.007a.75.75 0 0 0 .108 1.493V22.5l.2-.005A3.75 3.75 0 0 0 12 18.75Zm-6.5-3a.75.75 0 0 0-.75-.75l-.2.005a3.75 3.75 0 0 0 .2 7.495l.102-.007A.75.75 0 0 0 4.75 21l-.154-.005A2.25 2.25 0 0 1 4.75 16.5l.102-.007a.75.75 0 0 0 .648-.743Zm3.5 3a.75.75 0 0 0-.75-.75h-3.5l-.102.007A.75.75 0 0 0 4.75 19.5h3.5l.102-.007A.75.75 0 0 0 9 18.75Z",
|
||||
"document-list-clock-outline": "m19.414 8.414-5.829-5.828a.493.493 0 0 0-.049-.04.626.626 0 0 1-.036-.03 2.072 2.072 0 0 0-.219-.18.652.652 0 0 0-.08-.044l-.048-.024-.05-.029c-.054-.031-.109-.063-.166-.087a1.977 1.977 0 0 0-.624-.138c-.02-.001-.04-.004-.059-.007A.605.605 0 0 0 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h6.81a6.518 6.518 0 0 1-1.078-1.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v1.076c.523.081 1.026.224 1.5.422v-1.67a2 2 0 0 0-.586-1.414ZM13.5 4.621 17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621ZM10.75 17.5H11c0 .516.06 1.018.174 1.5h-.424a.75.75 0 0 1 0-1.5Zm.424-1.5c.125-.528.314-1.03.558-1.5h-.982a.75.75 0 0 0 0 1.5h.424Zm1.636-3a6.511 6.511 0 0 1 2.186-1.5H10.75a.75.75 0 0 0 0 1.5h2.06Zm-5.06-1.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM7 15.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm0 3a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm16-.75a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5.78.418a.5.5 0 0 1-.219-.489L17 13.5a.5.5 0 1 1 1 0L18.001 17h2.496a.5.5 0 0 1 0 1H17.56a.507.507 0 0 1-.34-.082Z",
|
||||
"draft-outline": "m20.877 2.826.153.144.145.153a3.579 3.579 0 0 1-.145 4.908L9.062 19.999a2.25 2.25 0 0 1-1 .58l-5.115 1.395a.75.75 0 0 1-.92-.921l1.394-5.116a2.25 2.25 0 0 1 .58-.999L15.97 2.97a3.579 3.579 0 0 1 4.908-.144ZM15 6.06l-9.938 9.938a.75.75 0 0 0-.193.333l-1.05 3.85 3.85-1.05A.75.75 0 0 0 8 18.938L17.94 9 15 6.06ZM6.525 11l-1.5 1.5H2.75a.75.75 0 0 1 0-1.5h3.775Zm4-4-1.5 1.5H2.75a.75.75 0 1 1 0-1.5h7.775Zm6.505-2.97-.97.97 2.939 2.94.97-.97a2.078 2.078 0 1 0-2.939-2.94ZM14.525 3l-1.5 1.5H2.75a.75.75 0 1 1 0-1.5h11.775Z",
|
||||
"drag-outline": "M15 3.707V8.5a.5.5 0 0 0 1 0V3.707l1.146 1.147a.5.5 0 0 0 .708-.708l-2-2a.499.499 0 0 0-.708 0l-2 2a.5.5 0 0 0 .708.708L15 3.707ZM2 4.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5Zm0 5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm.5 4.5a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6ZM15 16.293V11.5a.5.5 0 0 1 1 0v4.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 0 1 .708-.708L15 16.293Z",
|
||||
"dual-screen-clock-outline": "M10.019 6.002a6.632 6.632 0 0 0 .058 1.5H3.75a.25.25 0 0 0-.25.25v12.494c0 .138.112.25.25.25h7.498l-.001-10.167c.416.57.924 1.07 1.5 1.479v8.69h7.498a.25.25 0 0 0 .25-.25v-8.62a6.535 6.535 0 0 0 1.501-1.656V20.25a1.75 1.75 0 0 1-1.75 1.75h-8.998l-.001-.003H3.75A1.75 1.75 0 0 1 2 20.246V7.751c0-.966.784-1.75 1.75-1.75h6.269Zm6.22 11.498a.75.75 0 0 1 .101 1.493L16.24 19h-1.5a.75.75 0 0 1-.102-1.493l.102-.007h1.5Zm-6.996 0a.75.75 0 0 1 .102 1.493L9.243 19H7.74a.75.75 0 0 1-.102-1.493l.102-.007h1.502ZM16.498 1a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm-1 2a.5.5 0 0 0-.5.5v4a.5.5 0 0 0 .5.5h3.001a.5.5 0 0 0 0-1h-2.501V3.5a.5.5 0 0 0-.5-.5Z",
|
||||
@@ -125,6 +126,14 @@
|
||||
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
|
||||
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
|
||||
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
|
||||
"zoom-in-outline": [
|
||||
"M13.5 10a.75.75 0 0 0-.75-.75h-2v-2a.75.75 0 0 0-1.5 0v2h-2a.75.75 0 1 0 0 1.5h2v2a.75.75 0 0 0 1.5 0v-2h2a.75.75 0 0 0 .75-.75Z",
|
||||
"M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5 5.75 5.75 0 0 0 0-11.5Z"
|
||||
],
|
||||
"zoom-out-outline": [
|
||||
"M12.75 9.25a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h5.5Z",
|
||||
"M17.25 10a7.25 7.25 0 1 0-2.681 5.63l4.9 4.9.085.073a.75.75 0 0 0 .976-1.133l-4.9-4.901A7.22 7.22 0 0 0 17.25 10Zm-13 0a5.75 5.75 0 1 1 11.5 0 5.75 5.75 0 0 1-11.5 0Z"
|
||||
],
|
||||
"mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z",
|
||||
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
|
||||
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
|
||||
@@ -232,7 +241,6 @@
|
||||
"M14 0L13.9093 0.00622212C13.7497 0.0281315 13.6035 0.107093 13.4976 0.228504C13.3917 0.349915 13.3333 0.505562 13.3333 0.666661V1.33332H12.6667L12.576 1.33954C12.4164 1.36145 12.2701 1.44041 12.1642 1.56183C12.0584 1.68324 12 1.83888 12 1.99998L12.0062 2.09065C12.0281 2.25025 12.1071 2.39652 12.2285 2.50241C12.3499 2.60829 12.5056 2.66664 12.6667 2.66664H13.3333V3.3333L13.3396 3.42397C13.3615 3.58357 13.4404 3.72984 13.5618 3.83573C13.6833 3.94162 13.8389 3.99996 14 3.99996L14.0907 3.99374C14.416 3.9493 14.6667 3.67108 14.6667 3.3333V2.66664H15.3333L15.424 2.66042C15.7493 2.61598 16 2.33776 16 1.99998L15.9938 1.90932C15.9719 1.74971 15.8929 1.60345 15.7715 1.49756C15.6501 1.39167 15.4944 1.33333 15.3333 1.33332H14.6667V0.666661L14.6605 0.575995C14.6385 0.416393 14.5596 0.270123 14.4382 0.164236C14.3168 0.0583485 14.1611 6.79363e-06 14 0Z",
|
||||
"M16 12.0001L15.8187 12.0125C15.4995 12.0563 15.2069 12.2143 14.9951 12.4571C14.7834 12.6999 14.6667 13.0112 14.6667 13.3334V14.6667H13.3333L13.152 14.6792C12.8328 14.723 12.5403 14.8809 12.3285 15.1237C12.1167 15.3665 12 15.6778 12 16L12.0124 16.1814C12.0563 16.5006 12.2142 16.7931 12.457 17.0049C12.6998 17.2167 13.0111 17.3333 13.3333 17.3334H14.6667V18.6667L14.6791 18.848C14.7229 19.1672 14.8809 19.4598 15.1237 19.6715C15.3665 19.8833 15.6778 20 16 20L16.1813 19.9876C16.832 19.8987 17.3333 19.3422 17.3333 18.6667V17.3334H18.6667L18.848 17.3209C19.4987 17.232 20 16.6756 20 16L19.9876 15.8187C19.9437 15.4995 19.7858 15.207 19.543 14.9952C19.3002 14.7834 18.9889 14.6667 18.6667 14.6667H17.3333V13.3334L17.3209 13.1521C17.2771 12.8329 17.1191 12.5403 16.8763 12.3285C16.6335 12.1168 16.3222 12.0001 16 12.0001Z"
|
||||
],
|
||||
|
||||
"book-copy-outline": [
|
||||
"M3.66675 15.3334V5.33341C3.66675 4.89139 3.84234 4.46746 4.1549 4.1549C4.46746 3.84234 4.89139 3.66675 5.33341 3.66675H14.5001",
|
||||
"M6.16675 13.6667H5.33341C4.89139 13.6667 4.46746 13.8423 4.1549 14.1549C3.84234 14.4675 3.66675 14.8914 3.66675 15.3334C3.66675 15.7754 3.84234 16.1994 4.1549 16.5119C4.46746 16.8245 4.89139 17.0001 5.33341 17.0001H6.16675",
|
||||
|
||||
@@ -12,10 +12,10 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
process_email_for_channel(channel)
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error e
|
||||
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
channel.authorization_error!
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
|
||||
Rails.logger.error e
|
||||
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
rescue StandardError => e
|
||||
|
||||
@@ -2,14 +2,48 @@ module ActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include PriorityActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
include TeamActivityMessageHandler
|
||||
|
||||
private
|
||||
|
||||
def create_activity
|
||||
user_name = Current.user.name if Current.user.present?
|
||||
status_change_activity(user_name) if saved_change_to_status?
|
||||
priority_change_activity(user_name) if saved_change_to_priority?
|
||||
create_label_change(activity_message_ownner(user_name)) if saved_change_to_label_list?
|
||||
user_name = determine_user_name
|
||||
|
||||
handle_status_change(user_name)
|
||||
handle_priority_change(user_name)
|
||||
handle_label_change(user_name)
|
||||
handle_sla_policy_change(user_name)
|
||||
end
|
||||
|
||||
def determine_user_name
|
||||
Current.user&.name
|
||||
end
|
||||
|
||||
def handle_status_change(user_name)
|
||||
return unless saved_change_to_status?
|
||||
|
||||
status_change_activity(user_name)
|
||||
end
|
||||
|
||||
def handle_priority_change(user_name)
|
||||
return unless saved_change_to_priority?
|
||||
|
||||
priority_change_activity(user_name)
|
||||
end
|
||||
|
||||
def handle_label_change(user_name)
|
||||
return unless saved_change_to_label_list?
|
||||
|
||||
create_label_change(activity_message_owner(user_name))
|
||||
end
|
||||
|
||||
def handle_sla_policy_change(user_name)
|
||||
return unless saved_change_to_sla_policy_id?
|
||||
|
||||
sla_change_type = determine_sla_change_type
|
||||
create_sla_change_activity(sla_change_type, activity_message_owner(user_name))
|
||||
end
|
||||
|
||||
def status_change_activity(user_name)
|
||||
@@ -45,21 +79,6 @@ module ActivityMessageHandler
|
||||
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
|
||||
end
|
||||
|
||||
def create_label_added(user_name, labels = [])
|
||||
create_label_change_activity('added', user_name, labels)
|
||||
end
|
||||
|
||||
def create_label_removed(user_name, labels = [])
|
||||
create_label_change_activity('removed', user_name, labels)
|
||||
end
|
||||
|
||||
def create_label_change_activity(change_type, user_name, labels = [])
|
||||
return unless labels.size.positive?
|
||||
|
||||
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def create_muted_message
|
||||
create_mute_change_activity('muted')
|
||||
end
|
||||
@@ -75,30 +94,6 @@ module ActivityMessageHandler
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_team_change_activity_key
|
||||
team = Team.find_by(id: team_id)
|
||||
key = team.present? ? 'assigned' : 'removed'
|
||||
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
|
||||
key
|
||||
end
|
||||
|
||||
def generate_team_name_for_activity
|
||||
previous_team_id = previous_changes[:team_id][0]
|
||||
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
|
||||
end
|
||||
|
||||
def create_team_change_activity(user_name)
|
||||
user_name = activity_message_ownner(user_name)
|
||||
return unless user_name
|
||||
|
||||
key = generate_team_change_activity_key
|
||||
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
|
||||
params[:team_name] = generate_team_name_for_activity if key == 'removed'
|
||||
content = I18n.t("conversations.activity.team.#{key}", **params)
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name, user_name: user_name }.compact
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
@@ -107,7 +102,7 @@ module ActivityMessageHandler
|
||||
end
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_ownner(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
@@ -115,7 +110,7 @@ module ActivityMessageHandler
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def activity_message_ownner(user_name)
|
||||
def activity_message_owner(user_name)
|
||||
user_name = 'Automation System' if !user_name && Current.executed_by.present?
|
||||
user_name
|
||||
end
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module LabelActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_label_added(user_name, labels = [])
|
||||
create_label_change_activity('added', user_name, labels)
|
||||
end
|
||||
|
||||
def create_label_removed(user_name, labels = [])
|
||||
create_label_change_activity('removed', user_name, labels)
|
||||
end
|
||||
|
||||
def create_label_change_activity(change_type, user_name, labels = [])
|
||||
return unless labels.size.positive?
|
||||
|
||||
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module SlaActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_sla_change_activity(change_type, user_name)
|
||||
content = case change_type
|
||||
when 'added'
|
||||
I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name)
|
||||
when 'removed'
|
||||
I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name)
|
||||
when 'updated'
|
||||
I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name)
|
||||
end
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def sla_policy_name
|
||||
SlaPolicy.find_by(id: sla_policy_id)&.name || ''
|
||||
end
|
||||
|
||||
def determine_sla_change_type
|
||||
sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id]
|
||||
|
||||
if sla_policy_id_before.nil? && sla_policy_id_after.present?
|
||||
'added'
|
||||
elsif sla_policy_id_before.present? && sla_policy_id_after.nil?
|
||||
'removed'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module TeamActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_team_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
return unless user_name
|
||||
|
||||
key = generate_team_change_activity_key
|
||||
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
|
||||
params[:team_name] = generate_team_name_for_activity if key == 'removed'
|
||||
content = I18n.t("conversations.activity.team.#{key}", **params)
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_team_change_activity_key
|
||||
team = Team.find_by(id: team_id)
|
||||
key = team.present? ? 'assigned' : 'removed'
|
||||
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
|
||||
key
|
||||
end
|
||||
|
||||
def generate_team_name_for_activity
|
||||
previous_team_id = previous_changes[:team_id][0]
|
||||
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
|
||||
end
|
||||
end
|
||||
@@ -61,6 +61,7 @@ class Contact < ApplicationRecord
|
||||
after_create_commit :dispatch_create_event, :ip_lookup
|
||||
after_update_commit :dispatch_update_event
|
||||
after_destroy_commit :dispatch_destroy_event
|
||||
before_save :sync_contact_attributes
|
||||
|
||||
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
|
||||
|
||||
@@ -206,6 +207,10 @@ class Contact < ApplicationRecord
|
||||
self.custom_attributes = {} if custom_attributes.blank?
|
||||
end
|
||||
|
||||
def sync_contact_attributes
|
||||
::Contacts::SyncAttributes.new(self).perform
|
||||
end
|
||||
|
||||
def dispatch_create_event
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self)
|
||||
end
|
||||
|
||||
+26
-12
@@ -39,7 +39,10 @@ class Notification < ApplicationRecord
|
||||
conversation_assignment: 2,
|
||||
assigned_conversation_new_message: 3,
|
||||
conversation_mention: 4,
|
||||
participating_conversation_new_message: 5
|
||||
participating_conversation_new_message: 5,
|
||||
sla_missed_first_response: 6,
|
||||
sla_missed_next_response: 7,
|
||||
sla_missed_resolution: 8
|
||||
}.freeze
|
||||
|
||||
enum notification_type: NOTIFICATION_TYPES
|
||||
@@ -86,21 +89,32 @@ class Notification < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
# TODO: move to a data presenter
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def push_message_title
|
||||
case notification_type
|
||||
when 'conversation_creation'
|
||||
I18n.t('notifications.notification_title.conversation_creation', display_id: conversation.display_id, inbox_name: primary_actor.inbox.name)
|
||||
when 'conversation_assignment'
|
||||
I18n.t('notifications.notification_title.conversation_assignment', display_id: conversation.display_id)
|
||||
when 'assigned_conversation_new_message', 'participating_conversation_new_message'
|
||||
I18n.t('notifications.notification_title.assigned_conversation_new_message', display_id: conversation.display_id)
|
||||
when 'conversation_mention'
|
||||
I18n.t('notifications.notification_title.conversation_mention', display_id: conversation.display_id)
|
||||
notification_title_map = {
|
||||
'conversation_creation' => 'notifications.notification_title.conversation_creation',
|
||||
'conversation_assignment' => 'notifications.notification_title.conversation_assignment',
|
||||
'assigned_conversation_new_message' => 'notifications.notification_title.assigned_conversation_new_message',
|
||||
'participating_conversation_new_message' => 'notifications.notification_title.assigned_conversation_new_message',
|
||||
'conversation_mention' => 'notifications.notification_title.conversation_mention',
|
||||
'sla_missed_first_response' => 'notifications.notification_title.sla_missed_first_response',
|
||||
'sla_missed_next_response' => 'notifications.notification_title.sla_missed_next_response',
|
||||
'sla_missed_resolution' => 'notifications.notification_title.sla_missed_resolution'
|
||||
}
|
||||
|
||||
i18n_key = notification_title_map[notification_type]
|
||||
return '' unless i18n_key
|
||||
|
||||
if notification_type == 'conversation_creation'
|
||||
I18n.t(i18n_key, display_id: conversation.display_id, inbox_name: primary_actor.inbox.name)
|
||||
elsif %w[conversation_assignment assigned_conversation_new_message participating_conversation_new_message
|
||||
conversation_mention].include?(notification_type)
|
||||
I18n.t(i18n_key, display_id: conversation.display_id)
|
||||
else
|
||||
''
|
||||
I18n.t(i18n_key, display_id: primary_actor.display_id)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def push_message_body
|
||||
case notification_type
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
class Contacts::SyncAttributes
|
||||
attr_reader :contact
|
||||
|
||||
def initialize(contact)
|
||||
@contact = contact
|
||||
end
|
||||
|
||||
def perform
|
||||
update_contact_location_and_country_code
|
||||
set_contact_type
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_contact_location_and_country_code
|
||||
# Ensure that location and country_code are updated from additional_attributes.
|
||||
# TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
|
||||
@contact.location = @contact.additional_attributes['city']
|
||||
@contact.country_code = @contact.additional_attributes['country']
|
||||
end
|
||||
|
||||
def set_contact_type
|
||||
# If the contact is already a lead or customer then do not change the contact type
|
||||
return unless @contact.contact_type == 'visitor'
|
||||
# If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead
|
||||
# If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead
|
||||
return unless @contact.email.present? || @contact.phone_number.present? || social_details_present?
|
||||
|
||||
@contact.contact_type = 'lead'
|
||||
end
|
||||
|
||||
def social_details_present?
|
||||
@contact.additional_attributes.keys.any? do |key|
|
||||
key.start_with?('social_') && @contact.additional_attributes[key].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -130,6 +130,7 @@ class Telegram::IncomingMessageService
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: :location,
|
||||
fallback_title: location_fallback_title,
|
||||
coordinates_lat: location['latitude'],
|
||||
coordinates_long: location['longitude']
|
||||
)
|
||||
@@ -139,6 +140,16 @@ class Telegram::IncomingMessageService
|
||||
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
|
||||
end
|
||||
|
||||
def location_fallback_title
|
||||
return '' if venue.blank?
|
||||
|
||||
venue[:title] || ''
|
||||
end
|
||||
|
||||
def venue
|
||||
@venue ||= params.dig(:message, :venue).presence
|
||||
end
|
||||
|
||||
def location
|
||||
@location ||= params.dig(:message, :location).presence
|
||||
end
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation
|
||||
@@ -47,3 +47,4 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat
|
||||
json.last_activity_at conversation.last_activity_at.to_i
|
||||
json.priority conversation.priority
|
||||
json.waiting_since conversation.waiting_since.to_i.to_i
|
||||
json.sla_policy_id conversation.sla_policy_id
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.6.0'
|
||||
version: '3.7.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -122,6 +122,9 @@ en:
|
||||
conversation_assignment: "A conversation (#%{display_id}) has been assigned to you"
|
||||
assigned_conversation_new_message: "A new message is created in conversation (#%{display_id})"
|
||||
conversation_mention: "You have been mentioned in conversation (#%{display_id})"
|
||||
sla_missed_first_response: "SLA target first response missed for conversation (#%{display_id})"
|
||||
sla_missed_next_response: "SLA target next response missed for conversation (#%{display_id})"
|
||||
sla_missed_resolution: "SLA target resolution missed for conversation (#%{display_id})"
|
||||
attachment: "Attachment"
|
||||
no_content: "No content"
|
||||
conversations:
|
||||
@@ -155,6 +158,9 @@ en:
|
||||
labels:
|
||||
added: "%{user_name} added %{labels}"
|
||||
removed: "%{user_name} removed %{labels}"
|
||||
sla:
|
||||
added: "%{user_name} added SLA policy %{sla_name}"
|
||||
removed: "%{user_name} removed SLA policy %{sla_name}"
|
||||
muted: "%{user_name} has muted the conversation"
|
||||
unmuted: "%{user_name} has unmuted the conversation"
|
||||
templates:
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ Rails.application.routes.draw do
|
||||
namespace :channels do
|
||||
resource :twilio_channel, only: [:create]
|
||||
end
|
||||
resources :conversations, only: [:index, :create, :show] do
|
||||
resources :conversations, only: [:index, :create, :show, :update] do
|
||||
collection do
|
||||
get :meta
|
||||
get :search
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
def permitted_update_params
|
||||
super.merge(params.permit(:sla_policy_id))
|
||||
end
|
||||
end
|
||||
@@ -23,6 +23,13 @@ class AppliedSla < ApplicationRecord
|
||||
belongs_to :conversation
|
||||
|
||||
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
|
||||
before_validation :ensure_account_id
|
||||
|
||||
enum sla_status: { active: 0, hit: 1, missed: 2 }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_id
|
||||
self.account_id ||= sla_policy&.account_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,5 +3,35 @@ module Enterprise::EnterpriseConversationConcern
|
||||
|
||||
included do
|
||||
belongs_to :sla_policy, optional: true
|
||||
has_one :applied_sla, dependent: :destroy
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_sla_policy
|
||||
# TODO: remove these validations once we figure out how to deal with these cases
|
||||
if sla_policy_id.nil? && changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'cannot remove sla policy from conversation')
|
||||
return
|
||||
end
|
||||
|
||||
if changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'conversation already has a different sla')
|
||||
return
|
||||
end
|
||||
|
||||
errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id
|
||||
end
|
||||
|
||||
# handling inside a transaction to ensure applied sla record is also created
|
||||
def ensure_applied_sla_is_created
|
||||
ActiveRecord::Base.transaction do
|
||||
yield
|
||||
create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank?
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,4 +22,15 @@ class SlaPolicy < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
|
||||
has_many :conversations, dependent: :nullify
|
||||
has_many :applied_slas, dependent: :destroy
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
frt: first_response_time_threshold,
|
||||
nrt: next_response_time_threshold,
|
||||
rt: resolution_time_threshold
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,16 +8,5 @@ module Enterprise::ActionService
|
||||
|
||||
Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}"
|
||||
@conversation.update!(sla_policy_id: sla_policy.id)
|
||||
create_applied_sla(sla_policy)
|
||||
end
|
||||
|
||||
def create_applied_sla(sla_policy)
|
||||
Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}"
|
||||
AppliedSla.create!(
|
||||
account_id: @conversation.account_id,
|
||||
sla_policy_id: sla_policy.id,
|
||||
conversation_id: @conversation.id,
|
||||
sla_status: 'active'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,7 +30,7 @@ class Sla::EvaluateAppliedSlaService
|
||||
return if first_reply_was_within_threshold?(conversation, threshold)
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla)
|
||||
handle_missed_sla(applied_sla, 'frt')
|
||||
end
|
||||
|
||||
def first_reply_was_within_threshold?(conversation, threshold)
|
||||
@@ -46,7 +46,7 @@ class Sla::EvaluateAppliedSlaService
|
||||
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla)
|
||||
handle_missed_sla(applied_sla, 'nrt')
|
||||
end
|
||||
|
||||
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
|
||||
@@ -55,13 +55,14 @@ class Sla::EvaluateAppliedSlaService
|
||||
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla)
|
||||
handle_missed_sla(applied_sla, 'rt')
|
||||
end
|
||||
|
||||
def handle_missed_sla(applied_sla)
|
||||
def handle_missed_sla(applied_sla, type)
|
||||
return unless applied_sla.active?
|
||||
|
||||
applied_sla.update!(sla_status: 'missed')
|
||||
generate_notifications_for_sla(applied_sla, type)
|
||||
Rails.logger.warn "SLA missed for conversation #{applied_sla.conversation.id} " \
|
||||
"in account #{applied_sla.account_id} " \
|
||||
"for sla_policy #{applied_sla.sla_policy.id}"
|
||||
@@ -75,4 +76,30 @@ class Sla::EvaluateAppliedSlaService
|
||||
"in account #{applied_sla.account_id} " \
|
||||
"for sla_policy #{applied_sla.sla_policy.id}"
|
||||
end
|
||||
|
||||
def generate_notifications_for_sla(applied_sla, type)
|
||||
notify_users = applied_sla.conversation.conversation_participants.map(&:user)
|
||||
# add all admins from the account to notify list
|
||||
notify_users += applied_sla.account.administrators
|
||||
# ensure conversation assignee is notified
|
||||
notify_users += [applied_sla.conversation.assignee] if applied_sla.conversation.assignee.present?
|
||||
|
||||
notification_type = if type == 'frt'
|
||||
'sla_missed_first_response'
|
||||
elsif type == 'nrt'
|
||||
'sla_missed_next_response'
|
||||
else
|
||||
'sla_missed_resolution'
|
||||
end
|
||||
|
||||
notify_users.uniq.each do |user|
|
||||
NotificationBuilder.new(
|
||||
notification_type: notification_type,
|
||||
user: user,
|
||||
account: applied_sla.account,
|
||||
primary_actor: applied_sla.conversation,
|
||||
secondary_actor: applied_sla.sla_policy
|
||||
).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "3.6.0",
|
||||
"version": "3.7.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -67,21 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with confirmation required' do
|
||||
let(:unconfirmed_user) { create(:user, email: email) }
|
||||
|
||||
before do
|
||||
unconfirmed_user.confirmed_at = nil
|
||||
unconfirmed_user.save(validate: false)
|
||||
allow(unconfirmed_user).to receive(:confirmed?).and_return(false)
|
||||
end
|
||||
|
||||
it 'sends confirmation instructions' do
|
||||
user = agent_builder.perform
|
||||
expect(user).to receive(:send_confirmation_instructions)
|
||||
agent_builder.send(:send_confirmation_if_required)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -210,6 +210,55 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:params) { { priority: 'high' } }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'does not update the conversation if you do not have access to it' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an administrator' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an agent with access to inbox' do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/conversations' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
@@ -411,21 +460,6 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(conversation.reload.status).to eq('snoozed')
|
||||
expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until)
|
||||
end
|
||||
|
||||
# TODO: remove this spec when we remove the condition check in controller
|
||||
# Added for backwards compatibility for bot status
|
||||
# remove in next release
|
||||
# it 'toggles the conversation status to pending status when parameter bot is passed' do
|
||||
# expect(conversation.status).to eq('open')
|
||||
|
||||
# post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
# headers: agent.create_new_auth_token,
|
||||
# params: { status: 'bot' },
|
||||
# as: :json
|
||||
|
||||
# expect(response).to have_http_status(:success)
|
||||
# expect(conversation.reload.status).to eq('pending')
|
||||
# end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated bot' do
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Conversations API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:params) { { sla_policy_id: sla_policy.id } }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an agent with access to inbox' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id)
|
||||
end
|
||||
|
||||
it 'throws error if conversation already has a different sla' do
|
||||
conversation.update(sla_policy: create(:sla_policy, account: account))
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,37 @@ RSpec.describe Conversation, type: :model do
|
||||
it { is_expected.to belong_to(:sla_policy).optional }
|
||||
end
|
||||
|
||||
describe 'SLA policy updates' do
|
||||
let!(:conversation) { create(:conversation) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: conversation.account) }
|
||||
|
||||
it 'generates an activity message when the SLA policy is updated' do
|
||||
conversation.update!(sla_policy_id: sla_policy.id)
|
||||
|
||||
perform_enqueued_jobs
|
||||
|
||||
activity_message = conversation.messages.where(message_type: 'activity').last
|
||||
|
||||
expect(activity_message).not_to be_nil
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to include('added SLA policy')
|
||||
end
|
||||
|
||||
# TODO: Reenable this when we let the SLA policy be removed from a conversation
|
||||
# it 'generates an activity message when the SLA policy is removed' do
|
||||
# conversation.update!(sla_policy_id: sla_policy.id)
|
||||
# conversation.update!(sla_policy_id: nil)
|
||||
|
||||
# perform_enqueued_jobs
|
||||
|
||||
# activity_message = conversation.messages.where(message_type: 'activity').last
|
||||
|
||||
# expect(activity_message).not_to be_nil
|
||||
# expect(activity_message.message_type).to eq('activity')
|
||||
# expect(activity_message.content).to include('removed SLA policy')
|
||||
# end
|
||||
end
|
||||
|
||||
describe 'conversation sentiments' do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
@@ -34,4 +65,43 @@ RSpec.describe Conversation, type: :model do
|
||||
expect(sentiments[:label]).to eq('positive')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policy' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:different_account_sla_policy) { create(:sla_policy) }
|
||||
|
||||
context 'when sla_policy is getting updated' do
|
||||
it 'throws error if sla policy belongs to different account' do
|
||||
conversation.sla_policy = different_account_sla_policy
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch')
|
||||
end
|
||||
|
||||
it 'creates applied sla record if sla policy is present' do
|
||||
conversation.sla_policy = sla_policy
|
||||
conversation.save!
|
||||
expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation already has a different sla' do
|
||||
before do
|
||||
conversation.update(sla_policy: create(:sla_policy, account: account))
|
||||
end
|
||||
|
||||
it 'throws error if trying to assing a different sla' do
|
||||
conversation.sla_policy = sla_policy
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla'])
|
||||
end
|
||||
|
||||
it 'throws error if trying to set sla to nil' do
|
||||
conversation.sla_policy = nil
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
let!(:conversation) { create(:conversation, created_at: 6.hours.ago) }
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user_1) { create(:user, account: account) }
|
||||
let!(:user_2) { create(:user, account: account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
let!(:sla_policy) do
|
||||
create(:sla_policy, account: conversation.account,
|
||||
first_response_time_threshold: nil,
|
||||
next_response_time_threshold: nil,
|
||||
resolution_time_threshold: nil)
|
||||
create(:sla_policy,
|
||||
account: account,
|
||||
first_response_time_threshold: nil,
|
||||
next_response_time_threshold: nil,
|
||||
resolution_time_threshold: nil)
|
||||
end
|
||||
let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') }
|
||||
let!(:conversation) do
|
||||
create(:conversation,
|
||||
created_at: 6.hours.ago, assignee: user_1,
|
||||
account: sla_policy.account,
|
||||
sla_policy: sla_policy)
|
||||
end
|
||||
let!(:applied_sla) { conversation.applied_sla }
|
||||
|
||||
describe '#perform - SLA misses' do
|
||||
context 'when first response SLA is missed' do
|
||||
@@ -20,6 +31,16 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('missed')
|
||||
|
||||
expect(Notification.count).to eq(2)
|
||||
# check if notification type is sla_missed_first_response
|
||||
expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(2)
|
||||
# Check if notification is created for the assignee
|
||||
expect(Notification.where(user_id: user_1.id).count).to eq(1)
|
||||
# Check if notification is created for the account admin
|
||||
expect(Notification.where(user_id: admin.id).count).to eq(1)
|
||||
# Check if no notification is created for other user
|
||||
expect(Notification.where(user_id: user_2.id).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,6 +56,16 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('missed')
|
||||
|
||||
expect(Notification.count).to eq(2)
|
||||
# check if notification type is sla_missed_first_response
|
||||
expect(Notification.where(notification_type: 'sla_missed_next_response').count).to eq(2)
|
||||
# Check if notification is created for the assignee
|
||||
expect(Notification.where(user_id: user_1.id).count).to eq(1)
|
||||
# Check if notification is created for the account admin
|
||||
expect(Notification.where(user_id: admin.id).count).to eq(1)
|
||||
# Check if no notification is created for other user
|
||||
expect(Notification.where(user_id: user_2.id).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,6 +78,15 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('missed')
|
||||
|
||||
expect(Notification.count).to eq(2)
|
||||
expect(Notification.where(notification_type: 'sla_missed_resolution').count).to eq(2)
|
||||
# Check if notification is created for the assignee
|
||||
expect(Notification.where(user_id: user_1.id).count).to eq(1)
|
||||
# Check if notification is created for the account admin
|
||||
expect(Notification.where(user_id: admin.id).count).to eq(1)
|
||||
# Check if no notification is created for other user
|
||||
expect(Notification.where(user_id: user_2.id).count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,6 +116,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time
|
||||
expect(applied_sla.reload.sla_status).to eq('missed')
|
||||
expect(Notification.count).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -99,6 +140,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
|
||||
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
|
||||
expect(applied_sla.reload.sla_status).to eq('hit')
|
||||
expect(Notification.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ RSpec.describe Contact do
|
||||
it 'sets email to lowercase' do
|
||||
contact = create(:contact, email: 'Test@test.com')
|
||||
expect(contact.email).to eq('test@test.com')
|
||||
expect(contact.contact_type).to eq('lead')
|
||||
end
|
||||
|
||||
it 'sets email to nil when empty string' do
|
||||
contact = create(:contact, email: '')
|
||||
expect(contact.email).to be_nil
|
||||
expect(contact.contact_type).to eq('visitor')
|
||||
end
|
||||
|
||||
it 'sets custom_attributes to {} when nil' do
|
||||
@@ -75,4 +77,29 @@ RSpec.describe Contact do
|
||||
expect(contact.email).to eq 'test@test.com'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when city and country code passed in additional attributes' do
|
||||
it 'updates location and country code' do
|
||||
contact = create(:contact, additional_attributes: { city: 'New York', country: 'US' })
|
||||
expect(contact.location).to eq 'New York'
|
||||
expect(contact.country_code).to eq 'US'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact is created' do
|
||||
it 'has contact type "visitor" by default' do
|
||||
contact = create(:contact)
|
||||
expect(contact.contact_type).to eq 'visitor'
|
||||
end
|
||||
|
||||
it 'has contact type "lead" when email is present' do
|
||||
contact = create(:contact, email: 'test@test.com')
|
||||
expect(contact.contact_type).to eq 'lead'
|
||||
end
|
||||
|
||||
it 'has contact type "lead" when contacted through a social channel' do
|
||||
contact = create(:contact, additional_attributes: { social_facebook_user_id: '123' })
|
||||
expect(contact.contact_type).to eq 'lead'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# spec/services/contacts/sync_attributes_spec.rb
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::SyncAttributes do
|
||||
describe '#perform' do
|
||||
let(:contact) { create(:contact, additional_attributes: { 'city' => 'New York', 'country' => 'US' }) }
|
||||
|
||||
context 'when contact has neither email/phone number nor social details' do
|
||||
it 'does not change contact type' do
|
||||
described_class.new(contact).perform
|
||||
expect(contact.reload.contact_type).to eq('visitor')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has email or phone number' do
|
||||
it 'sets contact type to lead' do
|
||||
contact.email = 'test@test.com'
|
||||
contact.save
|
||||
described_class.new(contact).perform
|
||||
|
||||
expect(contact.reload.contact_type).to eq('lead')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has social details' do
|
||||
it 'sets contact type to lead' do
|
||||
contact.additional_attributes['social_facebook_user_id'] = '123456789'
|
||||
contact.save
|
||||
described_class.new(contact).perform
|
||||
|
||||
expect(contact.reload.contact_type).to eq('lead')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when location and country code are updated from additional attributes' do
|
||||
it 'updates location and country code' do
|
||||
described_class.new(contact).perform
|
||||
|
||||
# Expect location and country code to be updated
|
||||
expect(contact.reload.location).to eq('New York')
|
||||
expect(contact.reload.country_code).to eq('US')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -255,6 +255,30 @@ describe Telegram::IncomingMessageService do
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location')
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if venue is present' do
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => {
|
||||
'location': {
|
||||
'latitude': 37.7893768,
|
||||
'longitude': -122.3895553
|
||||
},
|
||||
venue: {
|
||||
title: 'San Francisco'
|
||||
}
|
||||
}.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
expect(telegram_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
|
||||
attachment = telegram_channel.inbox.messages.first.attachments.first
|
||||
expect(attachment.file_type).to eq('location')
|
||||
expect(attachment.coordinates_lat).to eq(37.7893768)
|
||||
expect(attachment.coordinates_long).to eq(-122.3895553)
|
||||
expect(attachment.fallback_title).to eq('San Francisco')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid callback_query params' do
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
tags:
|
||||
- Conversations
|
||||
operationId: update-conversation
|
||||
summary: Update Conversation
|
||||
description: Update Conversation Attributes
|
||||
security:
|
||||
- userApiKey: []
|
||||
- agentBotApiKey: []
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
priority:
|
||||
type: string
|
||||
enum: ["urgent", "high", "medium", "low", "none"]
|
||||
description: "The priority of the conversation"
|
||||
sla_policy_id:
|
||||
type: number
|
||||
description: "The ID of the SLA policy (Available only in Enterprise edition)"
|
||||
responses:
|
||||
200:
|
||||
description: Success
|
||||
404:
|
||||
description: Conversation not found
|
||||
401:
|
||||
description: Unauthorized
|
||||
@@ -339,6 +339,8 @@
|
||||
- $ref: '#/parameters/conversation_id'
|
||||
get:
|
||||
$ref: ./application/conversation/show.yml
|
||||
patch:
|
||||
$ref: ./application/conversation/update.yml
|
||||
/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status:
|
||||
parameters:
|
||||
- $ref: '#/parameters/account_id'
|
||||
|
||||
@@ -3319,6 +3319,64 @@
|
||||
"description": "Access denied"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Conversations"
|
||||
],
|
||||
"operationId": "update-conversation",
|
||||
"summary": "Update Conversation",
|
||||
"description": "Update Conversation Attributes",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"agentBotApiKey": [
|
||||
|
||||
]
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "data",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"urgent",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"none"
|
||||
],
|
||||
"description": "The priority of the conversation"
|
||||
},
|
||||
"sla_policy_id": {
|
||||
"type": "number",
|
||||
"description": "The ID of the SLA policy (Available only in Enterprise edition)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Conversation not found"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status": {
|
||||
|
||||
Reference in New Issue
Block a user