Merge branch 'develop' into extending-lock-to-single-conversation-to-meta-inbox
This commit is contained in:
@@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def contactable_inboxes
|
||||
|
||||
@@ -36,10 +36,18 @@ 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]
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def mute
|
||||
@@ -110,6 +118,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 +189,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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module DomainHelper
|
||||
def self.chatwoot_domain?(domain = request.host)
|
||||
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ module SwitchLocale
|
||||
def switch_locale(&)
|
||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||
locale ||= locale_from_params
|
||||
locale ||= locale_from_custom_domain
|
||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||
locale ||= locale_from_env_variable
|
||||
set_locale(locale, &)
|
||||
@@ -16,6 +17,20 @@ module SwitchLocale
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
# If the request is coming from a custom domain, it should be for a helpcenter portal
|
||||
# We will use the portal locale in such cases
|
||||
def locale_from_custom_domain(&)
|
||||
return if params[:locale]
|
||||
|
||||
domain = request.host
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
@portal = Portal.find_by(custom_domain: domain)
|
||||
return unless @portal
|
||||
|
||||
@portal.default_locale
|
||||
end
|
||||
|
||||
def set_locale(locale, &)
|
||||
# if locale is empty, use default_locale
|
||||
locale ||= I18n.default_locale
|
||||
|
||||
@@ -18,6 +18,7 @@ class DashboardController < ActionController::Base
|
||||
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
||||
'BRAND_URL', 'BRAND_NAME',
|
||||
'PRIVACY_URL',
|
||||
'DISPLAY_MANIFEST',
|
||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||
|
||||
@@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
@locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
'en'
|
||||
article.portal.default_locale
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
|
||||
@@ -8,8 +8,7 @@ class PublicController < ActionController::Base
|
||||
|
||||
def ensure_custom_domain_request
|
||||
domain = request.host
|
||||
|
||||
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
@portal = ::Portal.find_by(custom_domain: domain)
|
||||
return if @portal.present?
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
module FilterHelper
|
||||
def build_condition_query(model_filters, query_hash, current_index)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
# Throw InvalidOperator Error if the attribute is a standard attribute
|
||||
# and the operator is not allowed in the config
|
||||
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
|
||||
raise CustomExceptions::CustomFilter::InvalidOperator.new(
|
||||
attribute_name: query_hash['attribute_key'],
|
||||
allowed_keys: current_filter['filter_operators']
|
||||
)
|
||||
end
|
||||
|
||||
# Every other filter expects a value to be present
|
||||
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
|
||||
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
|
||||
end
|
||||
|
||||
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
|
||||
# The query becomes empty only when it doesn't match to any supported
|
||||
# standard attribute or custom attribute defined in the account.
|
||||
if condition_query.empty?
|
||||
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
|
||||
allowed_keys: model_filters.keys)
|
||||
end
|
||||
|
||||
condition_query
|
||||
end
|
||||
|
||||
def build_condition_query_string(current_filter, query_hash, current_index)
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
|
||||
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
|
||||
|
||||
case current_filter['attribute_type']
|
||||
when 'additional_attributes'
|
||||
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
|
||||
else
|
||||
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_nil_filter(query_hash, current_index)
|
||||
attribute_type = "#{filter_config[:entity].downcase}_attribute"
|
||||
custom_attribute_query(query_hash, attribute_type, current_index)
|
||||
end
|
||||
|
||||
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
|
||||
if data_type == 'text_case_insensitive'
|
||||
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
else
|
||||
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]} "
|
||||
end
|
||||
end
|
||||
|
||||
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
case current_filter['data_type']
|
||||
when 'date'
|
||||
date_filter(current_filter, query_hash, filter_operator_value)
|
||||
when 'labels'
|
||||
tag_filter_query(query_hash, current_index)
|
||||
when 'text_case_insensitive'
|
||||
text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
else
|
||||
default_filter(query_hash, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def date_filter(current_filter, query_hash, filter_operator_value)
|
||||
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
|
||||
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
end
|
||||
@@ -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,11 +1,10 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
// @import 'shared/assets/fonts/inter';
|
||||
|
||||
@import 'shared/assets/fonts/inter';
|
||||
// Inter,
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'PlusJakarta',
|
||||
Inter,
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity",
|
||||
"REFERER_LINK": "Referrer link"
|
||||
"REFERER_LINK": "Referrer link",
|
||||
"BLOCKED": "Blocked"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"SLA": {
|
||||
"HEADER": "SLA",
|
||||
"ADD_ACTION": "Add SLA",
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"HEADER_BTN_TXT": "Add SLA",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"SEARCH_404": "There are no items matching this query",
|
||||
@@ -9,7 +12,26 @@
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"TITLE": "Manage SLA",
|
||||
"DESC": "SLAs: Friendly promises for great service!",
|
||||
"TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
|
||||
"TABLE_HEADER": [
|
||||
"Name",
|
||||
"Description",
|
||||
"FRT",
|
||||
"NRT",
|
||||
"RT",
|
||||
"Business Hours"
|
||||
],
|
||||
"BUSINESS_HOURS_ON": "Business hours on",
|
||||
"BUSINESS_HOURS_OFF": "Business hours off",
|
||||
"RESPONSE_TYPES": {
|
||||
"FRT": "First response time threshold",
|
||||
"NRT": "Next response time threshold",
|
||||
"RT": "Resolution time threshold",
|
||||
"SHORT_HAND": {
|
||||
"FRT": "FRT",
|
||||
"NRT": "NRT",
|
||||
"RT": "RT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
@@ -55,11 +77,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 },
|
||||
|
||||
+1
-1
@@ -243,7 +243,7 @@ export default {
|
||||
attr.attribute_display_type === 'checkbox'
|
||||
);
|
||||
});
|
||||
if (isCustomAttributeCheckbox) {
|
||||
if (isCustomAttributeCheckbox || type === 'blocked') {
|
||||
return [
|
||||
{
|
||||
id: true,
|
||||
|
||||
@@ -76,6 +76,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
attributeI18nKey: 'BLOCKED',
|
||||
inputType: 'search_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
@@ -115,6 +123,10 @@ export const filterAttributeGroups = [
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
{
|
||||
key: 'blocked',
|
||||
i18nKey: 'BLOCKED',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full gap-10 font-inter">
|
||||
<slot name="header" />
|
||||
<slot name="body" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
keepAlive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full h-full px-5 pt-8 pb-3 m-0 overflow-auto bg-white sm:px-16 sm:pt-16 dark:bg-slate-900"
|
||||
>
|
||||
<div class="flex items-start max-w-[900px] w-full">
|
||||
<keep-alive v-if="keepAlive">
|
||||
<router-view />
|
||||
</keep-alive>
|
||||
<router-view v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
linkText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const openInNewTab = url => {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener noreferrer');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-4">
|
||||
<!-- Header section with icon, title and action button -->
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<!-- Icon and title container -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="iconName"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1
|
||||
class="text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Slot for additional actions on larger screens -->
|
||||
<div class="hidden gap-2 sm:flex">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description and optional link -->
|
||||
<div
|
||||
class="flex flex-col gap-2 text-slate-600 dark:text-slate-300 max-w-[721px] w-full"
|
||||
>
|
||||
<p
|
||||
class="mb-0 text-sm font-normal tracking-[0.5%] line-clamp-5 sm:line-clamp-none"
|
||||
>
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
<!-- Conditional link -->
|
||||
<a
|
||||
v-if="href && linkText"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="sm:inline-flex hidden tracking-[-0.6%] gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium tracking=[-0.6%] hover:underline"
|
||||
>
|
||||
{{ linkText }}
|
||||
<fluent-icon
|
||||
size="16"
|
||||
icon="chevron-right"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mobile view for actions and link -->
|
||||
<div class="flex items-start justify-start w-full gap-3 sm:hidden">
|
||||
<slot name="actions" />
|
||||
<woot-button
|
||||
v-if="href && linkText"
|
||||
color-scheme="secondary"
|
||||
icon="arrow-outwards"
|
||||
class="flex-row-reverse rounded-xl min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
|
||||
@click="openInNewTab(href)"
|
||||
>
|
||||
{{ linkText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm sm:divide-x sm:divide-slate-75 sm:dark:divide-slate-700/50 group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
|
||||
>
|
||||
<!-- left side section -->
|
||||
<slot name="leftSection">
|
||||
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
|
||||
<div
|
||||
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
|
||||
>
|
||||
<h3
|
||||
class="justify-between text-sm tracking-[-0.6%] font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</h3>
|
||||
<slot name="label" />
|
||||
</div>
|
||||
<p
|
||||
class="text-sm text-slate-600 tracking-[0.5%] dark:text-slate-300 max-w-[400px] w-full line-clamp-2"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- right side section -->
|
||||
<slot name="rightSection" />
|
||||
|
||||
<!-- actions section -->
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="absolute flex-col items-center hidden gap-1 border-none ltr:-right-3 rtl:-left-3 top-3 group-hover:flex"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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, {
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
hasBusinessHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center min-w-0 gap-1 px-1.5 sm:px-2 py-1 border border-solid rounded-lg border-slate-75 dark:border-slate-700/50"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="hasBusinessHours ? 'alarm-on' : 'alarm-off'"
|
||||
type="outline"
|
||||
class="flex-shrink-0"
|
||||
:class="
|
||||
hasBusinessHours
|
||||
? 'text-slate-600 dark:text-slate-400'
|
||||
: 'text-slate-300 dark:text-slate-700'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
class="hidden text-xs tracking-[0.2%] font-normal truncate sm:block"
|
||||
:class="
|
||||
hasBusinessHours
|
||||
? 'text-slate-600 dark:text-slate-400'
|
||||
: 'text-slate-300 dark:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{
|
||||
hasBusinessHours
|
||||
? $t('SLA.LIST.BUSINESS_HOURS_ON')
|
||||
: $t('SLA.LIST.BUSINESS_HOURS_OFF')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
</script>
|
||||
<template>
|
||||
<base-settings-header
|
||||
:title="$t('SLA.HEADER')"
|
||||
:description="$t('SLA.DESCRIPTION')"
|
||||
:link-text="$t('SLA.LEARN_MORE')"
|
||||
href="/"
|
||||
icon-name="document-list-clock"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
icon="plus-sign"
|
||||
class="rounded-xl"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
{{ $t('SLA.ADD_ACTION') }}
|
||||
</woot-button>
|
||||
</template>
|
||||
</base-settings-header>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
|
||||
import SLAResponseTime from './SLAResponseTime.vue';
|
||||
import SLABusinessHoursLabel from './SLABusinessHoursLabel.vue';
|
||||
|
||||
defineProps({
|
||||
slaName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstResponse: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
nextResponse: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
resolutionTime: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasBusinessHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<base-settings-list-item :title="slaName" :description="description">
|
||||
<template #label>
|
||||
<SLA-business-hours-label :has-business-hours="hasBusinessHours" />
|
||||
</template>
|
||||
<template #rightSection>
|
||||
<div
|
||||
class="flex items-center divide-x rtl:divide-x-reverse sm:rtl:!border-l-0 sm:rtl:!border-r sm:rtl:border-solid sm:rtl:border-slate-75 sm:rtl:dark:border-slate-700/50 gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between divide-slate-75 dark:divide-slate-700/50"
|
||||
>
|
||||
<SLA-response-time response-type="FRT" :response-time="firstResponse" />
|
||||
<SLA-response-time response-type="NRT" :response-time="nextResponse" />
|
||||
<SLA-response-time response-type="RT" :response-time="resolutionTime" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('SLA.FORM.DELETE')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="delete"
|
||||
class-names="grey-btn"
|
||||
:is-loading="isLoading"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</template>
|
||||
</base-settings-list-item>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
responseType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
responseTime: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row items-start w-full h-full gap-1 sm:items-end sm:px-6 sm:py-2 sm:gap-2 sm:flex-col"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 tracking-[-0.6%] text-sm ltr:pl-1.5 sm:ltr:pl-0 rtl:pr-1.5 sm:rtl:pr-0 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.left="$t(`SLA.LIST.RESPONSE_TYPES.${responseType}`)"
|
||||
size="14"
|
||||
icon="information"
|
||||
type="outline"
|
||||
class="flex-shrink-0 hidden text-sm font-normal sm:flex sm:font-medium text-slate-500 dark:text-slate-500"
|
||||
/>
|
||||
{{ $t(`SLA.LIST.RESPONSE_TYPES.SHORT_HAND.${responseType}`) }}
|
||||
<span class="flex sm:hidden">:</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-sm sm:text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ responseTime }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-else-if="showIcon"
|
||||
:size="iconSize"
|
||||
:icon="icon"
|
||||
class="flex-shrink-0"
|
||||
:class="className"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"add-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM12 7a.75.75 0 0 1 .75.75v3.5h3.5a.75.75 0 0 1 0 1.5h-3.5v3.5a.75.75 0 0 1-1.5 0v-3.5h-3.5a.75.75 0 0 1 0-1.5h3.5v-3.5A.75.75 0 0 1 12 7Z",
|
||||
"add-outline": "M11.75 3a.75.75 0 0 1 .743.648l.007.102.001 7.25h7.253a.75.75 0 0 1 .102 1.493l-.102.007h-7.253l.002 7.25a.75.75 0 0 1-1.493.101l-.007-.102-.002-7.249H3.752a.75.75 0 0 1-.102-1.493L3.752 11h7.25L11 3.75a.75.75 0 0 1 .75-.75Z",
|
||||
"add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z",
|
||||
"alarm-on-outline": "m10.95 13.7l-1.425-1.425q-.3-.3-.7-.3t-.7.3q-.3.3-.3.713t.3.712l2.125 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.3-.712t-.3-.713q-.3-.3-.713-.3t-.712.3L10.95 13.7ZM12 22q-1.875 0-3.512-.713t-2.85-1.924q-1.213-1.213-1.925-2.85T3 13q0-1.875.713-3.513t1.924-2.85q1.213-1.212 2.85-1.924T12 4q1.875 0 3.513.713t2.85 1.925q1.212 1.212 1.925 2.85T21 13q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 22Zm0-9ZM2.05 7.3q-.275-.275-.275-.7t.275-.7L4.9 3.05q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7L3.45 7.3q-.275.275-.7.275t-.7-.275Zm19.9 0q-.275.275-.7.275t-.7-.275L17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7ZM12 20q2.925 0 4.963-2.038T19 13q0-2.925-2.038-4.963T12 6Q9.075 6 7.037 8.038T5 13q0 2.925 2.038 4.963T12 20Z",
|
||||
"alarm-off-outline": "m19.95 17.25l-1.5-1.5q.275-.675.413-1.313T19 13.1q0-2.9-2.05-5T12 6q-.7 0-1.35.113t-1.3.387L7.85 5q.95-.5 1.988-.75T12 4q1.85 0 3.488.7t2.862 1.938q1.225 1.237 1.938 2.887T21 13.1q0 1.125-.275 2.163t-.775 1.987ZM17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275L17.7 4.45ZM12 22q-1.85 0-3.488-.7T5.65 19.4q-1.225-1.2-1.938-2.825T3 13.1q0-1.55.463-2.912T4.8 7.7l-.85-.85l-.5.5q-.275.275-.7.275t-.7-.275q-.275-.275-.275-.7t.275-.7l.5-.5L1.4 4.3q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l18.4 18.4q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.45-2.45q-1.125.825-2.487 1.288T12 22Zm0-1.975q1.05 0 2.05-.325t1.85-.9L6.2 9.15q-.575.875-.887 1.888T5 13.1q0 2.9 2.05 4.913T12 20.025Zm-.95-6.05Zm2.85-2.85Z",
|
||||
"alert-outline": "M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.251 1.251 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5ZM13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145l.006-.147ZM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.004-.225A5.988 5.988 0 0 0 12 3.496Z",
|
||||
"archive-outline": "M19.25 3c.966 0 1.75.784 1.75 1.75v2c0 .698-.408 1.3-1 1.581v9.919A3.75 3.75 0 0 1 16.25 22h-8.5A3.75 3.75 0 0 1 4 18.25V8.332A1.75 1.75 0 0 1 3 6.75v-2C3 3.784 3.784 3 4.75 3h14.5Zm-.75 5.5h-13v9.75a2.25 2.25 0 0 0 2.25 2.25h8.5a2.25 2.25 0 0 0 2.25-2.25V8.5Zm-8.5 3h4a.75.75 0 0 1 .102 1.493L14 13h-4a.75.75 0 0 1-.102-1.493L10 11.5h4-4Zm9.25-7H4.75a.25.25 0 0 0-.25.25v2c0 .138.112.25.25.25h14.5a.25.25 0 0 0 .25-.25v-2a.25.25 0 0 0-.25-.25Z",
|
||||
"arrow-chevron-left-outline": "M15 17.898c0 1.074-1.265 1.648-2.073.941l-6.31-5.522a1.75 1.75 0 0 1 0-2.634l6.31-5.522c.808-.707 2.073-.133 2.073.941v11.796Z",
|
||||
@@ -9,6 +11,7 @@
|
||||
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
|
||||
"arrow-download-outline": "M18.25 20.5a.75.75 0 1 1 0 1.5l-13 .004a.75.75 0 1 1 0-1.5l13-.004ZM11.648 2.012l.102-.007a.75.75 0 0 1 .743.648l.007.102-.001 13.685 3.722-3.72a.75.75 0 0 1 .976-.073l.085.073a.75.75 0 0 1 .072.976l-.073.084-4.997 4.997a.75.75 0 0 1-.976.073l-.085-.073-5.003-4.996a.75.75 0 0 1 .976-1.134l.084.072 3.719 3.714L11 2.755a.75.75 0 0 1 .648-.743l.102-.007-.102.007Z",
|
||||
"arrow-expand-outline": "M7.669 14.923a1 1 0 0 1 1.414 1.414l-2.668 2.667H8a1 1 0 0 1 .993.884l.007.116a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v1.587l2.669-2.668Zm8.336 6.081a1 1 0 1 1 0-2h1.583l-2.665-2.667a1 1 0 0 1-.083-1.32l.083-.094a1 1 0 0 1 1.414 0l2.668 2.67v-1.589a1 1 0 0 1 .883-.993l.117-.007a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4ZM8 3a1 1 0 0 1 0 2H6.417l2.665 2.668a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.414 0L5 6.412V8a1 1 0 0 1-.883.993L4 9a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4Zm12.005 0a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V6.412l-2.668 2.67a1 1 0 0 1-1.32.083l-.094-.083a1 1 0 0 1 0-1.414L17.589 5h-1.584a1 1 0 0 1-.993-.883L15.005 4a1 1 0 0 1 1-1h4Z",
|
||||
"arrow-outwards-outline": "m16 8.4l-8.875 8.9q-.3.3-.713.3t-.712-.3q-.3-.3-.3-.713t.3-.712L14.6 7H7q-.425 0-.713-.288T6 6q0-.425.288-.713T7 5h10q.425 0 .713.288T18 6v10q0 .425-.288.713T17 17q-.425 0-.713-.288T16 16V8.4Z",
|
||||
"arrow-redo-outline": "M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z",
|
||||
"arrow-right-import-outline": "M21.25 4.5a.75.75 0 0 1 .743.648L22 5.25v13.004a.75.75 0 0 1-1.493.102l-.007-.102V5.25a.75.75 0 0 1 .75-.75Zm-8.603 1.804l.072-.084a.75.75 0 0 1 .977-.073l.084.073l4.997 4.997a.75.75 0 0 1 .073.976l-.073.085l-4.997 5.003a.75.75 0 0 1-1.133-.976l.072-.084l3.711-3.717H2.75a.75.75 0 0 1-.743-.647L2 11.755a.75.75 0 0 1 .648-.743l.102-.007l13.693-.001l-3.724-3.724a.75.75 0 0 1-.072-.976l.072-.084l-.072.084Z",
|
||||
"arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z",
|
||||
@@ -118,6 +121,7 @@
|
||||
"headphones-sound-wave-outline": "M3.5 12a8.5 8.5 0 0 1 17 0v2h-2.25a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75H19a3 3 0 0 0 3-3v-7c0-5.523-4.477-10-10-10S2 6.477 2 12v7a3 3 0 0 0 3 3h.75a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75H3.5v-2Zm17 3.5V19a1.5 1.5 0 0 1-1.5 1.5v-5h1.5ZM3.5 19v-3.5H5v5A1.5 1.5 0 0 1 3.5 19Zm9.25-7.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5Zm-4 2.25a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Zm7.25.75a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0v-4.5Z",
|
||||
"image-outline": "M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm.58 16.401-5.805-5.686a.75.75 0 0 0-.966-.071l-.084.07-5.807 5.687c.182.064.378.099.582.099h11.5c.203 0 .399-.035.58-.099l-5.805-5.686L18.33 19.4ZM17.75 4.5H6.25A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .208.036.408.103.594l5.823-5.701a2.25 2.25 0 0 1 3.02-.116l.128.116 5.822 5.702c.067-.186.104-.386.104-.595V6.25a1.75 1.75 0 0 0-1.75-1.75Zm-2.498 2a2.252 2.252 0 1 1 0 4.504 2.252 2.252 0 0 1 0-4.504Zm0 1.5a.752.752 0 1 0 0 1.504.752.752 0 0 0 0-1.504Z",
|
||||
"info-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999Zm0 1.5a8.502 8.502 0 1 0 0 17.003A8.502 8.502 0 0 0 12 3.5Zm-.004 7a.75.75 0 0 1 .744.648l.007.102.003 5.502a.75.75 0 0 1-1.493.102l-.007-.101-.003-5.502a.75.75 0 0 1 .75-.75ZM12 7.003a.999.999 0 1 1 0 1.997.999.999 0 0 1 0-1.997Z",
|
||||
"information-outline": "M12 17q.425 0 .713-.288T13 16v-4q0-.425-.288-.713T12 11q-.425 0-.713.288T11 12v4q0 .425.288.713T12 17Zm0-8q.425 0 .713-.288T13 8q0-.425-.288-.713T12 7q-.425 0-.713.288T11 8q0 .425.288.713T12 9Zm0 13q-2.075 0-3.9-.788t-3.175-2.137q-1.35-1.35-2.137-3.175T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12q0 2.075-.788 3.9t-2.137 3.175q-1.35 1.35-3.175 2.138T12 22Zm0-2q3.35 0 5.675-2.325T20 12q0-3.35-2.325-5.675T12 4Q8.65 4 6.325 6.325T4 12q0 3.35 2.325 5.675T12 20Zm0-8Z",
|
||||
"key-outline": "M15 6a1 1 0 1 1-2 0a1 1 0 0 1 2 0Zm-2.5-4C9.424 2 7 4.424 7 7.5c0 .397.04.796.122 1.175c.058.27-.008.504-.142.638l-4.54 4.54A1.5 1.5 0 0 0 2 14.915V16.5A1.5 1.5 0 0 0 3.5 18h2A1.5 1.5 0 0 0 7 16.5V16h1a1 1 0 0 0 1-1v-1h1a1 1 0 0 0 1-1v-.18c.493.134 1.007.18 1.5.18c3.076 0 5.5-2.424 5.5-5.5S15.576 2 12.5 2ZM8 7.5C8 4.976 9.976 3 12.5 3S17 4.976 17 7.5S15.024 12 12.5 12c-.66 0-1.273-.095-1.776-.347A.5.5 0 0 0 10 12.1v.9H9a1 1 0 0 0-1 1v1H7a1 1 0 0 0-1 1v.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-1.586a.5.5 0 0 1 .146-.353l4.541-4.541c.432-.432.522-1.044.412-1.556A4.619 4.619 0 0 1 8 7.5Z",
|
||||
"keyboard-outline": "M19.745 5a2.25 2.25 0 0 1 2.25 2.25v9.505a2.25 2.25 0 0 1-2.25 2.25H4.25A2.25 2.25 0 0 1 2 16.755V7.25A2.25 2.25 0 0 1 4.25 5h15.495Zm0 1.5H4.25a.75.75 0 0 0-.75.75v9.505c0 .414.336.75.75.75h15.495a.75.75 0 0 0 .75-.75V7.25a.75.75 0 0 0-.75-.75Zm-12.995 8h10.5a.75.75 0 0 1 .102 1.493L17.25 16H6.75a.75.75 0 0 1-.102-1.493l.102-.007h10.5-10.5ZM16.5 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-5.995 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm6 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM6 8a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm2.995 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z",
|
||||
"library-outline": "M4 3h1c1.054 0 1.918.816 1.995 1.85L7 5v14a2.001 2.001 0 0 1-1.85 1.994L5 21H4a2.001 2.001 0 0 1-1.995-1.85L2 19V5c0-1.054.816-1.918 1.85-1.995L4 3h1-1Zm6 0h1c1.054 0 1.918.816 1.995 1.85L13 5v14a2.001 2.001 0 0 1-1.85 1.994L11 21h-1a2.001 2.001 0 0 1-1.995-1.85L8 19V5c0-1.054.816-1.918 1.85-1.995L10 3h1-1Zm6.974 2c.84 0 1.608.531 1.89 1.346l.047.157 3.015 11.745a2 2 0 0 1-1.296 2.392l-.144.043-.969.248a2.002 2.002 0 0 1-2.387-1.284l-.047-.155-3.016-11.745a2 2 0 0 1 1.298-2.392l.143-.043.968-.248c.166-.043.334-.064.498-.064ZM5 4.5H4a.501.501 0 0 0-.492.41L3.5 5v14c0 .244.177.45.41.492L4 19.5h1c.245 0 .45-.178.492-.41L5.5 19V5a.501.501 0 0 0-.41-.492L5 4.5Zm6 0h-1a.501.501 0 0 0-.492.41L9.5 5v14c0 .244.177.45.41.492l.09.008h1c.245 0 .45-.178.492-.41L11.5 19V5a.501.501 0 0 0-.41-.492L11 4.5Zm5.975 2-.063.004-.063.013-.968.247a.498.498 0 0 0-.376.51l.015.1 3.016 11.745a.5.5 0 0 0 .483.375l.063-.003.062-.012.97-.25a.5.5 0 0 0 .374-.519l-.015-.088-3.015-11.747a.501.501 0 0 0-.483-.375Z",
|
||||
@@ -126,6 +130,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",
|
||||
@@ -158,6 +170,7 @@
|
||||
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
|
||||
"person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z",
|
||||
"play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z",
|
||||
"plus-sign-outline": "M12 19q-.425 0-.713-.288T11 18v-5H6q-.425 0-.713-.288T5 12q0-.425.288-.713T6 11h5V6q0-.425.288-.713T12 5q.425 0 .713.288T13 6v5h5q.425 0 .713.288T19 12q0 .425-.288.713T18 13h-5v5q0 .425-.288.713T12 19Z",
|
||||
"power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z",
|
||||
"quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z",
|
||||
"repeat-outline": "m14.712 2.289l-.087-.078a1 1 0 0 0-1.327.078l-.078.087a.999.999 0 0 0 .078 1.326l1.299 1.297H8.999l-.24.004A6.997 6.997 0 0 0 2 11.993a6.94 6.94 0 0 0 1.189 3.899a.999.999 0 0 0 1.626-1.163l-.135-.218A4.997 4.997 0 0 1 9 6.998h5.595l-1.297 1.297l-.078.087a.999.999 0 0 0 1.492 1.326l3.006-3.003l.077-.087a.999.999 0 0 0-.078-1.326l-3.005-3.003Zm6.075 5.771A.999.999 0 0 0 19 8.677c0 .209.064.402.172.561a4.997 4.997 0 0 1-4.17 7.75H9.414l1.294-1.29l.083-.096a1 1 0 0 0-.006-1.23l-.077-.088l-.095-.084a1.001 1.001 0 0 0-1.232.006l-.088.078l-3.005 3.003l-.083.095a1 1 0 0 0 .006 1.231l.077.087l3.005 3.003l.095.084a1 1 0 0 0 1.397-1.41l-.077-.087l-1.304-1.303H15l.24-.003a6.997 6.997 0 0 0 5.546-10.927v.003Z",
|
||||
@@ -233,7 +246,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 }
|
||||
|
||||
@@ -168,7 +169,7 @@ class Contact < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.from_email(email)
|
||||
find_by(email: email.downcase)
|
||||
find_by(email: email&.downcase)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.from_email(email)
|
||||
find_by(email: email.downcase)
|
||||
find_by(email: email&.downcase)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,8 +5,8 @@ class AutomationRules::ConditionValidationService
|
||||
@rule = rule
|
||||
@account = rule.account
|
||||
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
|
||||
@@ -11,8 +11,9 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
@account = conversation.account
|
||||
|
||||
# setup filters from json file
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
|
||||
@@ -2,7 +2,7 @@ class Contacts::FilterService < FilterService
|
||||
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
|
||||
|
||||
def perform
|
||||
@contacts = contact_query_builder
|
||||
@contacts = query_builder(@filters['contacts'])
|
||||
|
||||
{
|
||||
contacts: @contacts,
|
||||
@@ -10,38 +10,6 @@ class Contacts::FilterService < FilterService
|
||||
}
|
||||
end
|
||||
|
||||
def contact_query_builder
|
||||
contact_filters = @filters['contacts']
|
||||
|
||||
@params[:payload].each_with_index do |query_hash, current_index|
|
||||
current_filter = contact_filters[query_hash['attribute_key']]
|
||||
@query_string += contact_query_string(current_filter, query_hash, current_index)
|
||||
end
|
||||
|
||||
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
||||
end
|
||||
|
||||
def contact_query_string(current_filter, query_hash, current_index)
|
||||
attribute_key = query_hash[:attribute_key]
|
||||
query_operator = query_hash[:query_operator]
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
|
||||
return custom_attribute_query(query_hash, 'contact_attribute', current_index) if current_filter.nil?
|
||||
|
||||
case current_filter['attribute_type']
|
||||
when 'additional_attributes'
|
||||
" LOWER(contacts.additional_attributes ->> '#{attribute_key}') #{filter_operator_value} #{query_operator} "
|
||||
when 'date_attributes'
|
||||
" (contacts.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
|
||||
when 'standard'
|
||||
if attribute_key == 'labels'
|
||||
" #{tag_filter_query('Contact', 'contacts', query_hash, current_index)} "
|
||||
else
|
||||
" LOWER(contacts.#{attribute_key}) #{filter_operator_value} #{query_operator} "
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def filter_values(query_hash)
|
||||
current_val = query_hash['values'][0]
|
||||
if query_hash['attribute_key'] == 'phone_number'
|
||||
@@ -57,6 +25,13 @@ class Contacts::FilterService < FilterService
|
||||
Current.account.contacts
|
||||
end
|
||||
|
||||
def filter_config
|
||||
{
|
||||
entity: 'Contact',
|
||||
table_name: 'contacts'
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def equals_to_filter_string(filter_operator, current_index)
|
||||
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ class Conversations::FilterService < FilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
@conversations = conversation_query_builder
|
||||
@conversations = query_builder(@filters['conversations'])
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
@@ -22,37 +22,6 @@ class Conversations::FilterService < FilterService
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_query_builder
|
||||
conversation_filters = @filters['conversations']
|
||||
@params[:payload].each_with_index do |query_hash, current_index|
|
||||
current_filter = conversation_filters[query_hash['attribute_key']]
|
||||
@query_string += conversation_query_string(current_filter, query_hash, current_index)
|
||||
end
|
||||
|
||||
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
||||
end
|
||||
|
||||
def conversation_query_string(current_filter, query_hash, current_index)
|
||||
attribute_key = query_hash[:attribute_key]
|
||||
query_operator = query_hash[:query_operator]
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
|
||||
return custom_attribute_query(query_hash, 'conversation_attribute', current_index) if current_filter.nil?
|
||||
|
||||
case current_filter['attribute_type']
|
||||
when 'additional_attributes'
|
||||
" conversations.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
|
||||
when 'date_attributes'
|
||||
" (conversations.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} "
|
||||
when 'standard'
|
||||
if attribute_key == 'labels'
|
||||
" #{tag_filter_query('Conversation', 'conversations', query_hash, current_index)} "
|
||||
else
|
||||
" conversations.#{attribute_key} #{filter_operator_value} #{query_operator} "
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def base_relation
|
||||
@account.conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
|
||||
@@ -63,6 +32,13 @@ class Conversations::FilterService < FilterService
|
||||
@params[:page] || 1
|
||||
end
|
||||
|
||||
def filter_config
|
||||
{
|
||||
entity: 'Conversation',
|
||||
table_name: 'conversations'
|
||||
}
|
||||
end
|
||||
|
||||
def conversations
|
||||
@conversations.sort_on_last_activity_at.page(current_page)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
require 'json'
|
||||
|
||||
class FilterService
|
||||
include FilterHelper
|
||||
include CustomExceptions::CustomFilter
|
||||
|
||||
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
|
||||
ATTRIBUTE_TYPES = {
|
||||
date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean'
|
||||
@@ -9,8 +12,8 @@ class FilterService
|
||||
def initialize(params, user)
|
||||
@params = params
|
||||
@user = user
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
@query_string = ''
|
||||
@filter_values = {}
|
||||
end
|
||||
@@ -106,7 +109,9 @@ class FilterService
|
||||
]
|
||||
end
|
||||
|
||||
def tag_filter_query(model_name, table_name, query_hash, current_index)
|
||||
def tag_filter_query(query_hash, current_index)
|
||||
model_name = filter_config[:entity]
|
||||
table_name = filter_config[:table_name]
|
||||
query_operator = query_hash[:query_operator]
|
||||
@filter_values["value_#{current_index}"] = filter_values(query_hash)
|
||||
|
||||
@@ -130,10 +135,8 @@ class FilterService
|
||||
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
|
||||
@attribute_key = query_hash[:attribute_key]
|
||||
@custom_attribute_type = custom_attribute_type
|
||||
|
||||
attribute_data_type
|
||||
|
||||
return ' ' if @custom_attribute.blank?
|
||||
return '' if @custom_attribute.blank?
|
||||
|
||||
build_custom_attr_query(query_hash, current_index)
|
||||
end
|
||||
@@ -155,9 +158,9 @@ class FilterService
|
||||
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
|
||||
|
||||
query = if attribute_data_type == 'text'
|
||||
" LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
|
||||
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
|
||||
else
|
||||
" (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
|
||||
"(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
|
||||
end
|
||||
|
||||
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
|
||||
@@ -194,4 +197,11 @@ class FilterService
|
||||
|
||||
"NOT LIKE :value_#{current_index}"
|
||||
end
|
||||
|
||||
def query_builder(model_filters)
|
||||
@params[:payload].each_with_index do |query_hash, current_index|
|
||||
@query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}"
|
||||
end
|
||||
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
||||
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
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
<option <%= locale == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= render partial: 'icons/chevron-down' %>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.6.0'
|
||||
version: '3.7.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -77,7 +77,9 @@ en:
|
||||
name: should not start or end with symbols, and it should not have < > / \ @ characters.
|
||||
custom_filters:
|
||||
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
|
||||
|
||||
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
|
||||
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
|
||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||
reports:
|
||||
period: Reporting period %{since} to %{until}
|
||||
utc_warning: The report generated is in UTC timezone
|
||||
@@ -158,6 +160,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,16 @@
|
||||
class CreateSlaEvents < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :sla_events do |t|
|
||||
t.references :applied_sla, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :account, null: false
|
||||
t.references :sla_policy, null: false
|
||||
t.references :inbox, null: false
|
||||
|
||||
t.integer :event_type
|
||||
t.jsonb :meta, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
+18
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -842,6 +842,23 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do
|
||||
t.index ["user_id"], name: "index_reporting_events_on_user_id"
|
||||
end
|
||||
|
||||
create_table "sla_events", force: :cascade do |t|
|
||||
t.bigint "applied_sla_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.integer "event_type"
|
||||
t.jsonb "meta", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_sla_events_on_account_id"
|
||||
t.index ["applied_sla_id"], name: "index_sla_events_on_applied_sla_id"
|
||||
t.index ["conversation_id"], name: "index_sla_events_on_conversation_id"
|
||||
t.index ["inbox_id"], name: "index_sla_events_on_inbox_id"
|
||||
t.index ["sla_policy_id"], name: "index_sla_events_on_sla_policy_id"
|
||||
end
|
||||
|
||||
create_table "sla_policies", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.float "first_response_time_threshold"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
def permitted_update_params
|
||||
super.merge(params.permit(:sla_policy_id))
|
||||
end
|
||||
end
|
||||
@@ -22,7 +22,16 @@ class AppliedSla < ApplicationRecord
|
||||
belongs_to :sla_policy
|
||||
belongs_to :conversation
|
||||
|
||||
has_many :sla_events, dependent: :destroy
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: sla_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# event_type :integer
|
||||
# meta :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# applied_sla_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# sla_policy_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_sla_events_on_account_id (account_id)
|
||||
# index_sla_events_on_applied_sla_id (applied_sla_id)
|
||||
# index_sla_events_on_conversation_id (conversation_id)
|
||||
# index_sla_events_on_inbox_id (inbox_id)
|
||||
# index_sla_events_on_sla_policy_id (sla_policy_id)
|
||||
#
|
||||
class SlaEvent < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :sla_policy
|
||||
belongs_to :applied_sla
|
||||
|
||||
enum event_type: { frt: 0, nrt: 1, rt: 2 }
|
||||
|
||||
before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id
|
||||
|
||||
private
|
||||
|
||||
def ensure_applied_sla_id
|
||||
self.applied_sla_id ||= AppliedSla.find_by(conversation_id: conversation_id)&.last&.id
|
||||
end
|
||||
|
||||
def ensure_account_id
|
||||
self.account_id ||= conversation&.account_id
|
||||
end
|
||||
|
||||
def ensure_inbox_id
|
||||
self.inbox_id ||= conversation&.inbox_id
|
||||
end
|
||||
|
||||
def ensure_sla_policy_id
|
||||
self.sla_policy_id ||= applied_sla&.sla_policy_id
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,7 @@ class SlaPolicy < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
|
||||
has_many :conversations, dependent: :nullify
|
||||
has_many :applied_slas, dependent: :destroy
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"conversations": {
|
||||
"status": {
|
||||
"attribute_name": "Status",
|
||||
"input_type": "multi_select",
|
||||
"table_name": "conversations",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"assignee_id": {
|
||||
"attribute_name": "Assignee Name",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"table_name": "conversations",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"contact_id": {
|
||||
"attribute_name": "Contact Name",
|
||||
"input_type": "plain_text",
|
||||
"table_name": "conversations",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"inbox_id": {
|
||||
"attribute_name": "Inbox Name",
|
||||
"input_type": "search_box",
|
||||
"table_name": "conversations",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"team_id": {
|
||||
"attribute_name": "Team Name",
|
||||
"input_type": "search_box",
|
||||
"table_name": "conversations",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"id": {
|
||||
"attribute_name": "Conversation Identifier",
|
||||
"input_type": "textbox",
|
||||
"table_name": "conversations",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"campaign_id": {
|
||||
"attribute_name": "Campaign Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"labels": {
|
||||
"attribute_name": "Labels",
|
||||
"input_type": "tags",
|
||||
"data_type": "text",
|
||||
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"browser_language": {
|
||||
"attribute_name": "Browser Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"conversation_language": {
|
||||
"attribute_name": "Conversation Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"mail_subject": {
|
||||
"attribute_name": "Email Subject",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"country_code": {
|
||||
"attribute_name": "Country Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"referer": {
|
||||
"attribute_name": "Referer link",
|
||||
"input_type": "textbox",
|
||||
"data_type": "link",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"plan": {
|
||||
"attribute_name": "Plan",
|
||||
"input_type": "multi_select",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"assignee_id": {
|
||||
"attribute_name": "Assignee Name",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"phone_number": {
|
||||
"attribute_name": "Phone Number",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"contact_id": {
|
||||
"attribute_name": "Contact Name",
|
||||
"input_type": "plain_text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"inbox_id": {
|
||||
"attribute_name": "Inbox Name",
|
||||
"input_type": "search_box",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"team_id": {
|
||||
"attribute_name": "Team Name",
|
||||
"input_type": "search_box",
|
||||
"data_type": "number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"id": {
|
||||
"attribute_name": "Conversation Identifier",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"campaign_id": {
|
||||
"attribute_name": "Campaign Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"labels": {
|
||||
"attribute_name": "Labels",
|
||||
"input_type": "tags",
|
||||
"data_type": "text",
|
||||
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"browser_language": {
|
||||
"attribute_name": "Browser Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"mail_subject": {
|
||||
"attribute_name": "Email Subject",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"email": {
|
||||
"attribute_name": "Email",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"country_code": {
|
||||
"attribute_name": "Country Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"referer": {
|
||||
"attribute_name": "Referer link",
|
||||
"input_type": "textbox",
|
||||
"data_type": "link",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
module CustomExceptions::CustomFilter
|
||||
class InvalidAttribute < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.custom_filters.invalid_attribute', key: @data[:key], allowed_keys: @data[:allowed_keys].join(','))
|
||||
end
|
||||
end
|
||||
|
||||
class InvalidOperator < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.custom_filters.invalid_operator', attribute_name: @data[:attribute_name], allowed_keys: @data[:allowed_keys].join(','))
|
||||
end
|
||||
end
|
||||
|
||||
class InvalidValue < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"attribute_key": "status",
|
||||
"attribute_name": "Status",
|
||||
"input_type": "multi_select",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "assigne",
|
||||
"attribute_name": "Assignee Name",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "contact",
|
||||
"attribute_name": "Contact Name",
|
||||
"input_type": "plain_text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "inbox",
|
||||
"attribute_name": "Inbox Name",
|
||||
"input_type": "search_box",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "team_id",
|
||||
"attribute_name": "Team Name",
|
||||
"input_type": "search_box",
|
||||
"data_type": "number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "id",
|
||||
"attribute_name": "Conversation Identifier",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "campaign_id",
|
||||
"attribute_name": "Campaign Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "labels",
|
||||
"attribute_name": "Labels",
|
||||
"input_type": "tags",
|
||||
"data_type": "text",
|
||||
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
{
|
||||
"attribute_key": "browser_language",
|
||||
"attribute_name": "Browser Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
{
|
||||
"attribute_key": "country_code",
|
||||
"attribute_name": "Country Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
{
|
||||
"attribute_key": "referer",
|
||||
"attribute_name": "Referer link",
|
||||
"input_type": "textbox",
|
||||
"data_type": "link",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
{
|
||||
"conversations": {
|
||||
"status": {
|
||||
"attribute_name": "Status",
|
||||
"input_type": "multi_select",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"assignee_id": {
|
||||
"attribute_name": "Assignee Name",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"contact_id": {
|
||||
"attribute_name": "Contact Name",
|
||||
"input_type": "plain_text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"inbox_id": {
|
||||
"attribute_name": "Inbox Name",
|
||||
"input_type": "search_box",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"team_id": {
|
||||
"attribute_name": "Team Name",
|
||||
"input_type": "search_box",
|
||||
"data_type": "number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"display_id": {
|
||||
"attribute_name": "Conversation Identifier",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"campaign_id": {
|
||||
"attribute_name": "Campaign Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "Number",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"labels": {
|
||||
"attribute_name": "Labels",
|
||||
"input_type": "tags",
|
||||
"data_type": "text",
|
||||
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"browser_language": {
|
||||
"attribute_name": "Browser Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"conversation_language": {
|
||||
"attribute_name": "Conversation Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"country_code": {
|
||||
"attribute_name": "Country Name",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"referer": {
|
||||
"attribute_name": "Referer link",
|
||||
"input_type": "textbox",
|
||||
"data_type": "link",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"created_at": {
|
||||
"attribute_name": "Created At",
|
||||
"input_type": "date",
|
||||
"data_type": "date",
|
||||
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
|
||||
"attribute_type": "date_attributes"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"attribute_name": "Created At",
|
||||
"input_type": "date",
|
||||
"data_type": "date",
|
||||
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
|
||||
"attribute_type": "date_attributes"
|
||||
},
|
||||
"mail_subject": {
|
||||
"attribute_name": "Email Subject",
|
||||
"input_type": "text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain"],
|
||||
"attribute_type": "additional_attributes"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"name": {
|
||||
"attribute_name": "Name",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"phone_number": {
|
||||
"attribute_name": "Phone Number",
|
||||
"input_type": "text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"email": {
|
||||
"attribute_name": "Email",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"identifier": {
|
||||
"attribute_name": "Contact Identifier",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"country_code": {
|
||||
"attribute_name": "Country",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"city": {
|
||||
"attribute_name": "City",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"browser_language": {
|
||||
"attribute_name": "Browser Language",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"company": {
|
||||
"attribute_name": "Company",
|
||||
"input_type": "textbox",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "additional_attributes"
|
||||
},
|
||||
"labels": {
|
||||
"attribute_name": "Labels",
|
||||
"input_type": "tags",
|
||||
"data_type": "text",
|
||||
"filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"created_at": {
|
||||
"attribute_name": "Created At",
|
||||
"input_type": "date",
|
||||
"data_type": "date",
|
||||
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
|
||||
"attribute_type": "date_attributes"
|
||||
},
|
||||
"last_activity_at": {
|
||||
"attribute_name": "Created At",
|
||||
"input_type": "date",
|
||||
"data_type": "date",
|
||||
"filter_operators": [ "is_greater_than", "is_less_than", "days_before" ],
|
||||
"attribute_type": "date_attributes"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"message_type": {
|
||||
"attribute_name": "Message Type",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "numeric",
|
||||
"filter_operators": [ "equal_to", "not_equal_to" ],
|
||||
"attribute_type": "standard"
|
||||
},
|
||||
"content": {
|
||||
"attribute_name": "Message Content",
|
||||
"input_type": "search_box with name tags/plain text",
|
||||
"data_type": "text",
|
||||
"filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ],
|
||||
"attribute_type": "standard"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
## This file contains the filter configurations which we use for the following
|
||||
# 1. Conversation Filters (app/services/filter_service.rb)
|
||||
# 2. Contact Filters (app/services/filter_service.rb)
|
||||
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
|
||||
|
||||
|
||||
# Format
|
||||
# - Parent Key (conversation, contact, messages)
|
||||
# - Key (attribute_name)
|
||||
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
|
||||
# - data_type: "text" : supported ["text", "text_case_insensitive", "number", "boolean", "labels", "date", "link"]
|
||||
# - filter_operators: ["equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present", "is_greater_than", "is_less_than", "days_before", "starts_with"]
|
||||
|
||||
### ----- Conversation Filters ----- ###
|
||||
|
||||
conversations:
|
||||
status:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
assignee_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
inbox_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
team_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
display_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "Number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
campaign_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "Number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
labels:
|
||||
attribute_type: "standard"
|
||||
data_type: "labels"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
browser_language:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
conversation_language:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
country_code:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
referer:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "link"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
created_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
last_activity_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
mail_subject:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
|
||||
### ----- End of Conversation Filters ----- ###
|
||||
|
||||
|
||||
### ----- Contact Filters ----- ###
|
||||
contacts:
|
||||
name:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
phone_number:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
- "starts_with"
|
||||
email:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
identifier:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
country_code:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
city:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
company:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
labels:
|
||||
attribute_type: "standard"
|
||||
data_type: "labels"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
created_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
last_activity_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
blocked:
|
||||
attribute_type: "standard"
|
||||
data_type: "boolean"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
|
||||
### ----- End of Contact Filters ----- ###
|
||||
|
||||
### ----- Message Filters ----- ###
|
||||
messages:
|
||||
message_type:
|
||||
attribute_type: "standard"
|
||||
data_type: "numeric"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
content:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
|
||||
### ----- End of Message Filters ----- ###
|
||||
+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}",
|
||||
|
||||
@@ -338,14 +338,18 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:contact1) { create(:contact, :with_email, account: account) }
|
||||
let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') }
|
||||
let!(:contact1) { create(:contact, :with_email, account: account, additional_attributes: { country_code: 'US' }) }
|
||||
let!(:contact2) do
|
||||
create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com', additional_attributes: { country_code: 'US' })
|
||||
end
|
||||
|
||||
it 'returns all contacts when query is empty' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/filter",
|
||||
params: {
|
||||
payload: []
|
||||
},
|
||||
params: { payload: [
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['US']
|
||||
] },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -353,6 +357,34 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
expect(response.body).to include(contact2.email)
|
||||
expect(response.body).to include(contact1.email)
|
||||
end
|
||||
|
||||
it 'returns error the query operator is invalid' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/filter",
|
||||
params: { payload: [
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'eq',
|
||||
values: ['US']
|
||||
] },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('Invalid operator. The allowed operators for country_code are [equal_to,not_equal_to]')
|
||||
end
|
||||
|
||||
it 'returns error the query value is invalid' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/filter",
|
||||
params: { payload: [
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: []
|
||||
] },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('Invalid value. The values provided for country_code are invalid"')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -152,17 +152,56 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'returns all conversations with empty query' do
|
||||
it 'returns all conversations matching the query' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/filter",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { payload: [] },
|
||||
params: {
|
||||
payload: [{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open']
|
||||
}]
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
|
||||
expect(response_data.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns error if the filters contain invalid attributes' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/filter",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: {
|
||||
payload: [{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open']
|
||||
}]
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data[:error]).to include('Invalid attribute key - [phone_number]')
|
||||
end
|
||||
|
||||
it 'returns error if the filters contain invalid operator' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/filter",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: {
|
||||
payload: [{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'eq',
|
||||
values: ['open']
|
||||
}]
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(response_data[:error]).to eq('Invalid operator. The allowed operators for status are [equal_to,not_equal_to].')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -210,6 +249,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 +499,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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SlaEvent, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:applied_sla) }
|
||||
it { is_expected.to belong_to(:conversation) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:sla_policy) }
|
||||
it { is_expected.to belong_to(:inbox) }
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid sla event object' do
|
||||
sla_event = create(:sla_event)
|
||||
expect(sla_event.event_type).to eq 'frt'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'backfilling ids' do
|
||||
it 'automatically backfills account_id, inbox_id, and sla_id upon creation' do
|
||||
sla_event = create(:sla_event)
|
||||
|
||||
expect(sla_event.account_id).to eq sla_event.conversation.account_id
|
||||
expect(sla_event.inbox_id).to eq sla_event.conversation.inbox_id
|
||||
expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,14 +5,21 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
let!(:user_1) { create(:user, account: account) }
|
||||
let!(:user_2) { create(:user, account: account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:conversation) { create(:conversation, created_at: 6.hours.ago, assignee: user_1, account: account) }
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
FactoryBot.define do
|
||||
factory :sla_event do
|
||||
applied_sla
|
||||
conversation
|
||||
event_type { 'frt' }
|
||||
account { conversation.account }
|
||||
inbox { conversation.inbox }
|
||||
sla_policy { applied_sla.sla_policy }
|
||||
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
|
||||
|
||||
@@ -7,9 +7,9 @@ describe Contacts::FilterService do
|
||||
let!(:first_user) { create(:user, account: account) }
|
||||
let!(:second_user) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let(:en_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'en' }) }
|
||||
let(:el_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'el' }) }
|
||||
let(:cs_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'cs' }) }
|
||||
let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) }
|
||||
let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) }
|
||||
let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_user, inbox: inbox)
|
||||
@@ -37,6 +37,8 @@ describe Contacts::FilterService do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
|
||||
before do
|
||||
en_contact.update_labels(%w[random_label support])
|
||||
cs_contact.update_labels('support')
|
||||
@@ -46,90 +48,7 @@ describe Contacts::FilterService do
|
||||
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
|
||||
end
|
||||
|
||||
context 'with query present' do
|
||||
let!(:params) { { payload: [], page: 1 } }
|
||||
let(:payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['en'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
context 'with label filter' do
|
||||
it 'returns equal_to filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 2
|
||||
expect(result[:contacts].first.label_list).to include('support')
|
||||
expect(result[:contacts].last.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'returns not_equal_to filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
end
|
||||
|
||||
it 'returns is_present filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_present',
|
||||
values: [],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 2
|
||||
expect(result[:contacts].first.label_list).to include('support')
|
||||
expect(result[:contacts].last.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'returns is_not_present filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_not_present',
|
||||
values: [],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
end
|
||||
end
|
||||
|
||||
it 'filter contacts by additional_attributes' do
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].first.id).to eq(en_contact.id)
|
||||
end
|
||||
|
||||
context 'with standard attributes - name' do
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
@@ -145,7 +64,168 @@ describe Contacts::FilterService do
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.name).to eq(en_contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - blocked' do
|
||||
it 'filter contacts by blocked' do
|
||||
blocked_contact = create(:contact, account: account, blocked: true)
|
||||
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'],
|
||||
query_operator: nil }.with_indifferent_access] }
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].first.id).to eq(blocked_contact.id)
|
||||
end
|
||||
|
||||
it 'filter contacts by not_blocked' do
|
||||
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: [false],
|
||||
query_operator: nil }.with_indifferent_access] }
|
||||
result = filter_service.new(params, first_user).perform
|
||||
# existing contacts are not blocked
|
||||
expect(result[:count]).to be 3
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - label' do
|
||||
it 'returns equal_to filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 2
|
||||
expect(result[:contacts].first.label_list).to include('support')
|
||||
expect(result[:contacts].last.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'returns not_equal_to filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: ['support'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
end
|
||||
|
||||
it 'returns is_present filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_present',
|
||||
values: [],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 2
|
||||
expect(result[:contacts].first.label_list).to include('support')
|
||||
expect(result[:contacts].last.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'returns is_not_present filter results properly' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'is_not_present',
|
||||
values: [],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.id).to eq el_contact.id
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - last_activity_at' do
|
||||
before do
|
||||
Time.zone = 'UTC'
|
||||
el_contact.update(last_activity_at: (Time.zone.today - 4.days))
|
||||
cs_contact.update(last_activity_at: (Time.zone.today - 5.days))
|
||||
en_contact.update(last_activity_at: (Time.zone.today - 2.days))
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 3_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [3],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'contact_additional_information',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['test custom data'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Contact.where(
|
||||
"last_activity_at < ? AND
|
||||
custom_attributes->>'contact_additional_information' = ?",
|
||||
(Time.zone.today - 3.days),
|
||||
'test custom data'
|
||||
).count
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].first.id).to eq(el_contact.id)
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 2_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [2],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with additional attributes' do
|
||||
let(:payload) do
|
||||
[
|
||||
{
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['uk'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
end
|
||||
|
||||
it 'filter contacts by additional_attributes' do
|
||||
params[:payload] = payload
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].first.id).to eq(en_contact.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom attributes' do
|
||||
it 'filter by custom_attributes and labels' do
|
||||
params[:payload] = [
|
||||
{
|
||||
@@ -181,9 +261,9 @@ describe Contacts::FilterService do
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['el'],
|
||||
values: ['GR'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
@@ -220,62 +300,6 @@ describe Contacts::FilterService do
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
|
||||
end
|
||||
|
||||
context 'with x_days_before filter' do
|
||||
before do
|
||||
Time.zone = 'UTC'
|
||||
el_contact.update(last_activity_at: (Time.zone.today - 4.days))
|
||||
cs_contact.update(last_activity_at: (Time.zone.today - 5.days))
|
||||
en_contact.update(last_activity_at: (Time.zone.today - 2.days))
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 3_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [3],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'contact_additional_information',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['test custom data'],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Contact.where(
|
||||
"last_activity_at < ? AND
|
||||
custom_attributes->>'contact_additional_information' = ?",
|
||||
(Time.zone.today - 3.days),
|
||||
'test custom data'
|
||||
).count
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].first.id).to eq(el_contact.id)
|
||||
end
|
||||
|
||||
it 'filter by last_activity_at 2_days_before and custom_attributes' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'last_activity_at',
|
||||
filter_operator: 'days_before',
|
||||
values: [2],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
|
||||
|
||||
result = filter_service.new(params, first_user).perform
|
||||
expect(result[:contacts].length).to be expected_count
|
||||
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
|
||||
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -55,7 +55,7 @@ describe Conversations::FilterService do
|
||||
[
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'contains',
|
||||
filter_operator: 'equal_to',
|
||||
values: 'en',
|
||||
query_operator: 'AND',
|
||||
custom_attribute_type: ''
|
||||
@@ -88,7 +88,7 @@ describe Conversations::FilterService do
|
||||
it 'filters items with contains filter_operator with values being an array' do
|
||||
params[:payload] = [{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'contains',
|
||||
filter_operator: 'equal_to',
|
||||
values: %w[tr fr],
|
||||
query_operator: '',
|
||||
custom_attribute_type: ''
|
||||
@@ -106,7 +106,7 @@ describe Conversations::FilterService do
|
||||
it 'filters items with does not contain filter operator with values being an array' do
|
||||
params[:payload] = [{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'does_not_contain',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: %w[tr en],
|
||||
query_operator: '',
|
||||
custom_attribute_type: ''
|
||||
@@ -291,6 +291,11 @@ describe Conversations::FilterService do
|
||||
end
|
||||
|
||||
it 'filter by custom_attributes and additional_attributes' do
|
||||
conversations = user_1.conversations
|
||||
conversations[0].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'silver' })
|
||||
conversations[1].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'platinum' })
|
||||
conversations[2].update!(additional_attributes: { 'browser_language': 'tr' }, custom_attributes: { conversation_type: 'platinum' })
|
||||
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'conversation_type',
|
||||
@@ -301,7 +306,7 @@ describe Conversations::FilterService do
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'is_equal_to',
|
||||
filter_operator: 'not_equal_to',
|
||||
values: 'en',
|
||||
query_operator: nil,
|
||||
custom_attribute_type: ''
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user