Compare commits

..
Author SHA1 Message Date
Shivam Mishra 6da2795b2c fix: add digest methods 2024-04-08 19:53:07 +05:30
Shivam Mishra f45f357e16 feat: remove admins 2024-04-08 19:51:32 +05:30
Shivam Mishra 2066e2ba3b feat: disable default email notifs for SLAs 2024-04-08 19:40:01 +05:30
Shivam Mishra e4c6f9c014 refactor: reduce looping and use each_with_object 2024-04-08 19:14:04 +05:30
Shivam Mishra e3150562c0 refactor: return sla events from the evaluate service 2024-04-08 19:04:46 +05:30
Shivam Mishra 0e772d729b refactor: group sla users 2024-04-08 18:54:18 +05:30
Shivam Mishra 7bcf35538f feat: allow bubbling up missed slas 2024-04-08 15:55:20 +05:30
Shivam Mishra c43fd46d3e refactor: don't use magic strings 2024-04-08 15:55:20 +05:30
Shivam Mishra 76a22ee0f3 refactor: eager load associations 2024-04-08 15:55:20 +05:30
Shivam Mishra bb6cd13110 refactor: perform SLA evaluation directly instead of queueing it 2024-04-08 15:55:20 +05:30
Sivin VargheseandGitHub aef4b582db fix: Update the SLA alert styles to match the labels (#9199) 2024-04-05 09:06:52 -07:00
Shivam MishraandGitHub 69ebdc63e3 feat: better empty state for SLA (#9198)
* feat: better empty state

* feat: better create CTA

* feat: use text-sm
2024-04-05 11:45:05 +05:30
Shivam MishraandGitHub 2397f817fb fix: add priority in filter keys [CW-3177] (#9179)
* fix: ConditionValidationService fails on conversation priority

* fix: custom attribute clash in spec
2024-04-05 10:03:48 +05:30
Vishnu NarayananandGitHub 95c83725d7 chore: add db_reset make command (#9197) 2024-04-05 12:11:30 +11:00
fa3bc05d3d chore: fix sla email notifications (#9192)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-04-04 21:16:49 +05:30
Sojan JoseandGitHub 18bfda35b3 chore: Fix agent API returning 500 error (#9193)
- We were using the @user object in the view, which wasn't available when using api_access_token.
- Also replaced the object to @agent and added additional specs to confirm the right object

fixes: https://linear.app/chatwoot/issue/CW-3185/agent-create-api-throwing-500-response
ref: https://discord.com/channels/647412545203994635/1219928138163949568/1219928138163949568
2024-04-04 19:44:47 +05:30
5c9acfb4e9 chore: Handle duplicate Webhooks from slack (#9187)
* chore: Handle duplicate Webhooks from slack

* chore: fixes

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-04-04 17:00:45 +05:30
Sivin VargheseandGitHub e49ef773d8 feat: UI to show the SLA threshold in chat screen (#9146)
- UI will show the breach in the conversation list.
- UI will show the breach in the conversation header.

Fixes: https://linear.app/chatwoot/issue/CW-3146/update-the-ui-to-show-the-breach-in-the-conversation-list
Fixes: https://linear.app/chatwoot/issue/CW-3144/ui-update-to-show-the-breachgoing-to-breach
2024-04-04 15:46:46 +05:30
e21d7552d3 feat: extending lock to single conversation to meta inbox (#9104)
This change introduces the ability to lock conversations to a single thread for Instagram and facebook messages within the Meta inbox, mirroring existing functionality in WhatsApp and SMS inboxes.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-03 21:33:23 +05:30
6b7a707fef chore: Security upgrade omniauth-google-oauth2 from 1.1.1 to 1.1.2 (#9173)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-04-03 20:22:46 +05:30
Muhsin KelothandGitHub 727fa67735 feat: SLA reports store (#9185)
- Added sla reports actions, getters and mutations.
2024-04-03 12:53:31 +05:30
Muhsin KelothandGitHub fc25f43448 feat: Add SLA reports overview component (#9167) 2024-04-02 15:08:19 -07:00
Muhsin KelothandGitHub 631598b6b6 chore: Fix twilio inbox create transaction rollback (#9181)
chore: Fix twilio create transaction
2024-04-02 15:04:10 +05:30
Sojan JoseandGitHub 4e28481f27 feat: Conversation API to return applied_sla and sla_events (#9174)
* chore: Add sla_events to push_event_data

* chore: Return SLA details in the API

* chore: feature lock sla push event data

* Update _conversation.json.jbuilder

* chore: rubocop fixes
2024-04-01 23:30:07 +05:30
16282f6a66 feat: Add push/email notification support for SLA (#9140)
* feat: update SLA evaluation logic

* Update enterprise/app/services/sla/evaluate_applied_sla_service.rb

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>

* chore: refactor spec to bring down expecations in a single block

* chore: fix process_account_applied_sla spec

* chore: add spec to test multiple nrt misses

* feat: persist sla notifications

* feat: revert persist sla notifications

* feat: add SLA push/email notification support

* chore: refactor sla_status to include active_with_misses

* chore: add support for sla push/email notifications

* chore: refactor

* chore: add liquid templates

* chore: add spec for liquid templates

* chore: add spec for sla email notifications

* chore: add spec for SlaPolicyDrop

* chore: refactor to ee namespace

* chore: set enterprise test type to mailer

* feat: enable sla notification settings only if SLA enabled

* chore: refactor

* chore: fix spec

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-03-29 20:27:21 +11:00
100 changed files with 1990 additions and 950 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
## Gems for reponse bot
+17 -12
View File
@@ -237,9 +237,8 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.7.4)
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
@@ -247,7 +246,8 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.0.2)
faraday-net_http (3.1.0)
net-http
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
@@ -394,7 +394,8 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.7.0)
jwt (2.8.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -470,6 +471,8 @@ GEM
multipart-post (2.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.9)
@@ -515,11 +518,11 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.1)
omniauth-google-oauth2 (1.1.2)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
oauth2 (~> 2.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (~> 1.8)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
@@ -559,7 +562,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.3)
rack (2.2.8.1)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.4.0)
@@ -568,7 +571,8 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (3.1.0)
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
@@ -790,11 +794,12 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (4.0.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.3)
version_gem (1.1.4)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -907,7 +912,7 @@ DEPENDENCIES
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)
pg
+4 -1
View File
@@ -17,6 +17,9 @@ db_migrate:
db_seed:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
db_reset:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset
db:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
@@ -49,4 +52,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
@@ -53,7 +53,23 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_or_build_for_multiple_conversations
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def build_conversation
@@ -69,9 +69,28 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
) || build_conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_or_build_for_multiple_conversations
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@@ -19,7 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
account: Current.account
)
builder.perform
@agent = builder.perform
end
def update
@@ -2,13 +2,9 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
before_action :authorize_request
def create
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
rescue StandardError => e
render_could_not_create_error(e.message)
end
process_create
rescue StandardError => e
render_could_not_create_error(e.message)
end
private
@@ -17,6 +13,14 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
authorize ::Inbox
end
def process_create
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
end
end
def authenticate_twilio
client = if permitted_params[:api_key_sid].present?
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
@@ -25,3 +25,4 @@ class ApplicationController < ActionController::Base
}
end
end
ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern')
+7 -2
View File
@@ -163,10 +163,14 @@ class ConversationFinder
params[:page] || 1
end
def conversations
@conversations = @conversations.includes(
def conversations_base_query
@conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
end
def conversations
@conversations = conversations_base_query
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations = @conversations.send(sort_by, sort_order)
@@ -178,3 +182,4 @@ class ConversationFinder
end
end
end
ConversationFinder.prepend_mod_with('ConversationFinder')
@@ -56,7 +56,6 @@ class SLAReportsAPI extends ApiClient {
team_id,
sla_policy_id,
} = {}) {
// no ratings for metrics
return axios.get(`${this.url}/metrics`, {
params: {
since: from,
@@ -0,0 +1,98 @@
import SLAReportsAPI from '../slaReports';
import ApiClient from '../ApiClient';
describe('#SLAReports API', () => {
it('creates correct instance', () => {
expect(SLAReportsAPI).toBeInstanceOf(ApiClient);
expect(SLAReportsAPI.apiVersion).toBe('/api/v1');
expect(SLAReportsAPI).toHaveProperty('get');
expect(SLAReportsAPI).toHaveProperty('getMetrics');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get', () => {
SLAReportsAPI.get({
page: 1,
from: 1622485800,
to: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', {
params: {
page: 1,
since: 1622485800,
until: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
},
});
});
it('#getMetrics', () => {
SLAReportsAPI.getMetrics({
from: 1622485800,
to: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/applied_slas/metrics',
{
params: {
since: 1622485800,
until: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
},
}
);
});
it('#download', () => {
SLAReportsAPI.download({
from: 1622485800,
to: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/applied_slas/download',
{
params: {
since: 1622485800,
until: 1623695400,
assigned_agent_id: 1,
inbox_id: 1,
team_id: 1,
sla_policy_id: 1,
},
}
);
});
});
});
@@ -1,14 +1,14 @@
<template>
<div
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{
hide: !showConversationList,
'list--full-width': isOnExpandedLayout,
}"
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
]"
>
<slot />
<div
class="flex items-center justify-between py-0 px-4"
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
@@ -16,7 +16,7 @@
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl break-words overflow-hidden whitespace-nowrap font-medium text-ellipsis text-black-900 dark:text-slate-100 mb-0"
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
@@ -107,7 +107,7 @@
<p
v-if="!chatListLoading && !conversationList.length"
class="overflow-auto p-4 flex justify-center items-center"
class="flex items-center justify-center p-4 overflow-auto"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
@@ -127,7 +127,7 @@
/>
<div
ref="conversationList"
class="conversations-list flex-1"
class="flex-1 conversations-list"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<virtual-list
@@ -136,16 +136,16 @@
:data-sources="conversationList"
:data-component="itemComponent"
:extra-props="virtualListExtraProps"
class="w-full overflow-auto h-full"
class="w-full h-full overflow-auto"
footer-tag="div"
>
<template #footer>
<div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
<span class="mt-4 mb-4 spinner" />
</div>
<p
v-if="showEndOfListMessage"
class="text-center text-slate-400 dark:text-slate-300 p-4"
class="p-4 text-center text-slate-400 dark:text-slate-300"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
@@ -1034,24 +1034,10 @@ export default {
</style>
<style scoped lang="scss">
.conversations-list-wrap {
&.hide {
@apply hidden;
}
&.list--full-width {
@apply basis-full;
}
}
.conversations-list {
@apply overflow-hidden hover:overflow-y-auto;
}
.load-more--button {
@apply text-center rounded-none;
}
.tab--chat-type {
@apply py-0 px-4;
@@ -12,7 +12,6 @@ const reports = accountId => ({
'label_reports',
'inbox_reports',
'team_reports',
'sla_reports',
],
menuItems: [
{
@@ -72,13 +71,6 @@ const reports = accountId => ({
toState: frontendURL(`accounts/${accountId}/reports/teams`),
toStateName: 'team_reports',
},
{
icon: 'document-list-clock',
label: 'REPORTS_SLA',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/sla`),
toStateName: 'sla_reports',
},
],
});
@@ -1,6 +1,6 @@
<template>
<div
class="ltr:mr-1 rtl:ml-1 mb-1"
class="inline-flex ltr:mr-1 rtl:ml-1 mb-1"
:class="labelClass"
:style="labelStyle"
:title="description"
@@ -111,7 +111,7 @@ export default {
<style scoped lang="scss">
.label {
@apply inline-flex items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
&.small {
@apply text-xs py-0.5 px-1 leading-tight h-5;
@@ -86,7 +86,11 @@
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<card-labels :conversation-id="chat.id" />
<card-labels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<template v-if="hasSlaPolicyId" #before>
<SLA-card-label :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</card-labels>
</div>
<woot-context-menu
v-if="showContextMenu"
@@ -125,6 +129,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
export default {
components: {
@@ -135,6 +140,7 @@ export default {
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
},
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
@@ -252,6 +258,9 @@ export default {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
onCardClick(e) {
@@ -1,12 +1,12 @@
<template>
<div
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
class="flex flex-col items-center justify-between px-4 py-2 bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 md:flex-row"
>
<div
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
>
<div class="flex justify-start items-center min-w-0 w-fit max-w-full">
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<back-button
v-if="showBackButton"
:back-url="backButtonUrl"
@@ -19,10 +19,10 @@
:status="currentContact.availability_status"
/>
<div
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden"
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full"
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
>
<woot-button
variant="link"
@@ -31,7 +31,7 @@
@click.prevent="$emit('contact-panel-toggle')"
>
<span
class="text-base leading-tight font-medium text-slate-900 dark:text-slate-100"
class="text-base font-medium leading-tight text-slate-900 dark:text-slate-100"
>
{{ currentContact.name }}
</span>
@@ -46,7 +46,7 @@
</div>
<div
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap"
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<inbox-name v-if="hasMultipleInboxes" :inbox="inbox" />
<span
@@ -67,9 +67,10 @@
</div>
</div>
<div
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0"
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -85,6 +86,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
@@ -95,6 +97,7 @@ export default {
InboxName,
MoreActions,
Thumbnail,
SLACardLabel,
},
mixins: [inboxMixin, agentMixin, eventListenerMixins],
props: {
@@ -173,6 +176,9 @@ export default {
hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1;
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
@@ -1,47 +1,63 @@
<template>
<div
class="flex items-center px-2 truncate border min-w-fit border-slate-75 dark:border-slate-700"
:class="showExtendedInfo ? 'py-[5px] rounded-lg' : 'py-0.5 gap-1 rounded'"
v-if="hasSlaThreshold"
class="relative flex items-center border cursor-pointer min-w-fit border-slate-100 dark:border-slate-700"
:class="showExtendedInfo ? 'h-[26px] rounded-lg' : 'rounded h-5'"
>
<div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-75 dark:border-slate-700'
"
class="flex items-center w-full truncate"
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
@mouseover="openSlaPopover()"
@mouseleave="closeSlaPopover()"
>
<fluent-icon
size="14"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
class="flex-shrink-0"
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
class="text-xs font-medium"
:class="slaTextStyles"
<div
class="flex items-center gap-1"
:class="
showExtendedInfo &&
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-100 dark:border-slate-700'
"
>
{{ slaStatusText }}
<fluent-icon
size="14"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
class="flex-shrink-0"
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
class="text-xs font-medium"
:class="slaTextStyles"
>
{{ slaStatusText }}
</span>
</div>
<span
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
</span>
</div>
<span
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
</span>
<SLA-popover-card
v-if="showSlaPopoverCard"
:all-missed-slas="slaEvents"
class="right-0 top-7"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
// const REFRESH_INTERVAL = 60000;
const REFRESH_INTERVAL = 60000;
export default {
components: {
SLAPopoverCard,
},
props: {
chat: {
type: Object,
@@ -55,19 +71,27 @@ export default {
data() {
return {
timer: null,
slaStatus: {},
showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
};
},
computed: {
...mapGetters({
activeSLA: 'sla/getSLAById',
}),
slaPolicyId() {
return this.chat?.sla_policy_id;
},
sla() {
if (!this.slaPolicyId) return null;
return this.activeSLA(this.slaPolicyId);
appliedSLA() {
return this.chat?.applied_sla;
},
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
@@ -79,12 +103,17 @@ export default {
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'BREACH' : 'DUE';
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
},
showSlaPopoverCard() {
return (
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
},
},
watch: {
chat() {
@@ -93,10 +122,29 @@ export default {
},
mounted() {
this.updateSlaStatus();
this.createTimer();
},
beforeDestroy() {
if (this.timer) {
clearTimeout(this.timer);
}
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.updateSlaStatus();
this.createTimer();
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.sla, this.chat);
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
},
},
};
@@ -0,0 +1,45 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
allMissedSlas: {
type: Array,
default: () => [],
},
});
const formatDate = timestamp => format(fromUnixTime(timestamp), 'PP');
const upperCase = str => str.toUpperCase();
</script>
<template>
<div
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4"
>
<div
v-for="missedSLA in allMissedSlas"
:key="missedSLA.id"
class="flex items-center justify-between w-full"
>
<span
class="text-sm font-normal tracking-[-0.6%] w-[140px] truncate text-slate-900 dark:text-slate-50"
>
{{
$t(
`CONVERSATION.HEADER.SLA_POPOVER.${upperCase(missedSLA.event_type)}`
)
}}
</span>
<span
class="text-sm font-normal tracking-[-0.6%] text-slate-600 dark:text-slate-200"
>
{{ $t('CONVERSATION.HEADER.SLA_POPOVER.MISSED') }}
</span>
<span
class="text-sm font-normal tracking-[-0.6%] text-slate-900 dark:text-slate-50"
>
{{ formatDate(missedSLA.created_at) }}
</span>
</div>
</div>
</template>
@@ -1,13 +1,14 @@
<template>
<div
v-show="activeLabels.length"
v-if="activeLabels.length || $slots.before"
ref="labelContainer"
class="label-container mt-0.5 mx-2 mb-0"
v-resize="computeVisibleLabelPosition"
>
<div
class="labels-wrap flex items-end min-w-0 flex-shrink gap-y-1 flex-wrap"
:class="{ expand: showAllLabels }"
class="flex items-end flex-shrink min-w-0 gap-y-1"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label.id"
@@ -26,7 +27,7 @@
? $t('CONVERSATION.CARD.HIDE_LABELS')
: $t('CONVERSATION.CARD.SHOW_LABELS')
"
class="show-more--button sticky flex-shrink-0 right-0 mr-6 rtl:rotate-180"
class="sticky right-0 flex-shrink-0 mr-6 show-more--button rtl:rotate-180"
color-scheme="secondary"
variant="hollow"
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
@@ -45,11 +46,6 @@ export default {
type: Number,
required: true,
},
conversationLabels: {
type: String,
required: false,
default: '',
},
},
data() {
return {
@@ -64,26 +60,34 @@ export default {
},
},
mounted() {
// the problem here is that there is a certain amount of delay between the conversation
// card being mounted and the resize event eventually being triggered
// This means we need to run the function immediately after the component is mounted
// Happens especially when used in a virtual list.
// We can make the first trigger, a standard part of the directive, in case
// we face this issue again
this.computeVisibleLabelPosition();
},
methods: {
onShowLabels(e) {
e.stopPropagation();
this.showAllLabels = !this.showAllLabels;
this.$nextTick(() => this.computeVisibleLabelPosition());
},
computeVisibleLabelPosition() {
const beforeSlot = this.$slots.before ? 100 : 0;
const labelContainer = this.$refs.labelContainer;
const labels = this.$refs.labelContainer.querySelectorAll('.label');
if (!labelContainer) return;
const labels = Array.from(labelContainer.querySelectorAll('.label'));
let labelOffset = 0;
this.showExpandLabelButton = false;
Array.from(labels).forEach((label, index) => {
labels.forEach((label, index) => {
labelOffset += label.offsetWidth + 8;
if (labelOffset < labelContainer.clientWidth - 16) {
if (labelOffset < labelContainer.clientWidth - 16 - beforeSlot) {
this.labelPosition = index;
} else {
this.showExpandLabelButton = true;
this.showExpandLabelButton = labels.length > 1;
}
});
},
@@ -100,10 +104,6 @@ export default {
}
.labels-wrap {
&.expand {
@apply h-auto overflow-visible flex-row flex-wrap;
}
.secondary {
@apply border border-solid border-slate-100 dark:border-slate-700;
}
@@ -0,0 +1,117 @@
const calculateThreshold = (timeOffset, threshold) => {
// Calculate the time left for the SLA to breach or the time since the SLA has missed
if (threshold === null) return null;
const currentTime = Math.floor(Date.now() / 1000);
return timeOffset + threshold - currentTime;
};
const findMostUrgentSLAStatus = SLAStatuses => {
// Sort the SLAs based on the threshold and return the most urgent SLA
SLAStatuses.sort(
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
);
return SLAStatuses[0];
};
const formatSLATime = seconds => {
const units = {
y: 31536000, // 60 * 60 * 24 * 365
mo: 2592000, // 60 * 60 * 24 * 30
d: 86400, // 60 * 60 * 24
h: 3600, // 60 * 60
m: 60,
};
if (seconds < 60) {
return '1m';
}
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
const parts = [];
Object.keys(units).forEach(unit => {
const value = Math.floor(seconds / units[unit]);
if (seconds < 60 && parts.length > 0) return;
if (parts.length === 2) return;
if (value > 0) {
parts.push(value + unit);
seconds -= value * units[unit];
}
});
return parts.join(' ');
};
const createSLAObject = (
type,
{
sla_first_response_time_threshold: frtThreshold,
sla_next_response_time_threshold: nrtThreshold,
sla_resolution_time_threshold: rtThreshold,
created_at: createdAt,
} = {},
{
first_reply_created_at: firstReplyCreatedAt,
waiting_since: waitingSince,
status,
} = {}
) => {
// Mapping of breach types to their logic
const SLATypes = {
FRT: {
threshold: calculateThreshold(createdAt, frtThreshold),
// Check FRT only if threshold is not null and first reply hasn't been made
condition:
frtThreshold !== null &&
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
},
NRT: {
threshold: calculateThreshold(waitingSince, nrtThreshold),
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
condition:
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
},
RT: {
threshold: calculateThreshold(createdAt, rtThreshold),
// Check RT only if the conversation is open and threshold is not null
condition: status === 'open' && rtThreshold !== null,
},
};
const SLAStatus = SLATypes[type];
return SLAStatus ? { ...SLAStatus, type } : null;
};
const evaluateSLAConditions = (appliedSla, chat) => {
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
const SLATypes = ['FRT', 'NRT', 'RT'];
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
.map(SLAStatus => ({
...SLAStatus,
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: SLAStatus.threshold <= 0,
}));
};
export const evaluateSLAStatus = (appliedSla, chat) => {
if (!appliedSla || !chat)
return { type: '', threshold: '', icon: '', isSlaMissed: false };
// Filter out the SLA and create the object for each breach
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
// Return the most urgent SLA which is latest to breach or has missed
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
return mostUrgent
? {
type: mostUrgent.type,
threshold: formatSLATime(
mostUrgent.threshold <= 0
? -mostUrgent.threshold
: mostUrgent.threshold
),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
}
: { type: '', threshold: '', icon: '', isSlaMissed: false };
};
@@ -0,0 +1,150 @@
import { evaluateSLAStatus } from '../SLAHelper';
beforeEach(() => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('SLAHelper', () => {
describe('evaluateSLAStatus', () => {
it('returns an empty object when sla or chat is not present', () => {
expect(evaluateSLAStatus(null, null)).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
// Case when FRT SLA is missed
it('correctly identifies a missed FRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066540,
};
const chatMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when FRT SLA is not missed
it('correctly identifies an FRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066660,
};
const chatNotMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when NRT SLA is missed
it('correctly identifies a missed NRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704065940,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when NRT SLA is not missed
it('correctly identifies an NRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200 - 2000,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704066060,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when RT SLA is missed
it('correctly identifies a missed RT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065340,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when RT SLA is not missed
it('correctly identifies an RT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065460,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
});
});
@@ -69,8 +69,14 @@
"FRT": "FRT {status}",
"NRT": "NRT {status}",
"RT": "RT {status}",
"BREACH": "breach",
"MISSED": "missed",
"DUE": "due"
},
"SLA_POPOVER": {
"FRT": "First Response Time",
"NRT": "Next Response Time",
"RT": "Resolution Time",
"MISSED": "missed on"
}
},
"RESOLVE_DROPDOWN": {
@@ -508,7 +508,6 @@
},
"SLA_REPORTS": {
"HEADER": "SLA Reports",
"NO_RECORDS": "There are no SLA reports available.",
"METRICS": {
"HIT_RATE": {
"LABEL": "Hit Rate",
@@ -518,13 +517,6 @@
"LABEL": "Number of Breaches",
"TOOLTIP": "The total SLA breaches in a certain period."
}
},
"TABLE": {
"HEADER": {
"POLICY_BREACHED": "Policy breached",
"CONVERSATION": "Conversation",
"AGENT": "Agent"
}
}
}
}
@@ -83,7 +83,10 @@
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created",
"CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation"
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation",
"SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
@@ -98,7 +101,10 @@
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation",
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
"REQUEST_PUSH": "Enable push notifications"
"REQUEST_PUSH": "Enable push notifications",
"SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA"
},
"PROFILE_IMAGE": {
"LABEL": "Profile Image"
@@ -239,7 +245,6 @@
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"REPORTS_SLA": "SLA",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"SLA": "SLA",
"BETA": "Beta",
@@ -2,11 +2,18 @@
"SLA": {
"HEADER": "SLA",
"ADD_ACTION": "Add SLA",
"ADD_ACTION_LONG": "Create a new SLA Policy",
"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",
"LOADING": "Fetching SLAs",
"LIST": {
"404": "There are no SLAs available in this account.",
"EMPTY": {
"TITLE_1": "Enterprise P0",
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
"TITLE_2": "Enterprise P1",
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
},
"BUSINESS_HOURS_ON": "Business hours on",
"BUSINESS_HOURS_OFF": "Business hours off",
"RESPONSE_TYPES": {
@@ -78,4 +85,4 @@
}
}
}
}
}
@@ -4,14 +4,11 @@ export default {
computed: {
...mapGetters({ accountLabels: 'labels/getLabels' }),
savedLabels() {
// If conversationLabels is passed as prop, use it
if (this.conversationLabels)
return this.conversationLabels.split(',').map(item => item.trim());
// Otherwise, get labels from store
return this.$store.getters['conversationLabels/getConversationLabels'](
this.conversationId
);
},
// TODO - Get rid of this from the mixin
activeLabels() {
return this.accountLabels.filter(({ title }) =>
this.savedLabels.includes(title)
@@ -589,7 +589,9 @@ export default {
return this.inbox.name;
},
canLocktoSingleConversation() {
return this.isASmsInbox || this.isAWhatsAppChannel;
return (
this.isASmsInbox || this.isAWhatsAppChannel || this.isAFacebookInbox
);
},
inboxNameLabel() {
if (this.isAWebWidgetInbox) {
@@ -236,6 +236,54 @@
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_first_response"
@input="handleEmailInput"
/>
<label for="sla_missed_first_response">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_next_response"
@input="handleEmailInput"
/>
<label for="sla_missed_next_response">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_sla_missed_resolution"
@input="handleEmailInput"
/>
<label for="sla_missed_resolution">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
)
}}
</label>
</div>
</div>
</div>
<div
@@ -352,6 +400,57 @@
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_first_response"
@input="handlePushInput"
/>
<label for="sla_missed_first_response">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_FIRST_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_next_response"
@input="handlePushInput"
/>
<label for="sla_missed_next_response">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_NEXT_RESPONSE'
)
}}
</label>
</div>
<div v-if="isSLAEnabled" class="flex items-center gap-2 mb-1">
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_sla_missed_resolution"
@input="handlePushInput"
/>
<label for="sla_missed_resolution">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.SLA_MISSED_RESOLUTION'
)
}}
</label>
</div>
</div>
</div>
</div>
@@ -367,6 +466,7 @@ import {
requestPushPermissions,
verifyServiceWorkerExistence,
} from '../../../../helper/pushHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
mixins: [alertMixin, configMixin, uiSettingsMixin],
@@ -393,13 +493,18 @@ export default {
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
uiSettings: 'getUISettings',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasPushAPISupport() {
return !!('Notification' in window);
},
isSLAEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA);
},
},
watch: {
emailFlags(value) {
@@ -1,57 +0,0 @@
<template>
<div class="flex flex-col flex-1 gap-6 p-4 overflow-auto font-inter">
<!-- <SLA-filters /> -->
<SLAMetrics
:hit-rate="slaMetrics.hitRate"
:no-of-breaches="slaMetrics.numberOfSLABreaches"
:is-loading="uiFlags.isLoading"
/>
<SLATable
:sla-reports="slaReports"
:current-page="Number(slaMeta.currentPage)"
:total-count="Number(slaMeta.count)"
@page-change="onPageChange"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import SLAMetrics from './components/SLA/SLAMetrics.vue';
import SLATable from './components/SLA/SLATable.vue';
// import SLAFilters from './components/SLA/SLAFilters.vue';
export default {
name: 'SLAReports',
components: {
SLAMetrics,
SLATable,
// SLAFilters,
},
data() {
return {
pageNumber: 1,
};
},
computed: {
...mapGetters({
slaReports: 'slaReports/getAll',
slaMetrics: 'slaReports/getMetrics',
slaMeta: 'slaReports/getMeta',
uiFlags: 'slaReports/getUIFlags',
}),
},
mounted() {
this.$store.dispatch('slaReports/getMetrics');
this.fetchSLAReports();
},
methods: {
fetchSLAReports({ pageNumber } = {}) {
this.$store.dispatch('slaReports/get', {
page: pageNumber || this.pageNumber,
});
},
onPageChange(pageNumber) {
this.fetchSLAReports({ pageNumber });
},
},
};
</script>
@@ -1,55 +0,0 @@
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
}
</style>
@@ -1,66 +0,0 @@
<template>
<div
class="menu text-slate-700 dark:text-slate-200 font-medium text-sm h-11 flex items-center hover:bg-slate-50 dark:hover:bg-slate-100"
role="button"
@click.stop="$emit('click')"
>
<span
v-if="variant === 'label' && option.color"
class="label-pill"
:style="{ backgroundColor: option.color }"
/>
<thumbnail
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label overflow-hidden whitespace-nowrap text-ellipsis">
{{ option.label }}
</p>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
},
};
</script>
<style scoped lang="scss">
.menu {
width: calc(var(--space-mega) * 2);
@apply flex items-center flex-nowrap p-1 rounded-sm overflow-hidden cursor-pointer;
.menu-label {
@apply my-0 mx-2 text-xs flex-shrink-0;
}
// &:hover {
// @apply bg-woot-500 dark:bg-woot-500 text-white dark:text-slate-50;
// }
}
.agent-thumbnail {
margin-top: 0 !important;
}
.label-pill {
@apply w-4 h-4 rounded-full border border-slate-50 border-solid dark:border-slate-900 flex-shrink-0;
}
</style>
@@ -1,122 +0,0 @@
<template>
<div
class="bg-white dark:bg-slate- rounded-xl border-slate-100 dark:border-slate-900 border-solid border"
>
<menu-item
:option="unreadOption"
variant="icon"
@click="$emit('mark-as-unread')"
/>
<menu-item-with-submenu :option="priorityConfig">
<menu-item
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
/>
</menu-item-with-submenu>
<template v-for="option in statusMenuConfig">
<menu-item
v-if="show(option.key)"
:key="option.key"
:option="option"
variant="icon"
/>
</template>
<menu-item
v-if="show(snoozeOption.key)"
:option="snoozeOption"
variant="icon"
/>
<menu-item-with-submenu :option="priorityConfig">
<menu-item
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
/>
</menu-item-with-submenu>
</div>
</template>
<script>
import MenuItem from './MenuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
export default {
components: {
MenuItem,
MenuItemWithSubmenu,
},
mixins: [agentMixin],
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
},
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark',
},
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
{
key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo',
},
],
snoozeOption: {
key: wootConstants.STATUS_TYPE.SNOOZED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
},
priorityConfig: {
key: 'priority',
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
},
].filter(item => item.key !== this.priority),
},
};
},
computed: {
...mapGetters({}),
},
methods: {
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
return this.status !== key;
},
},
};
</script>
@@ -1,50 +0,0 @@
<template>
<div>
<woot-button
color-scheme="secondary"
icon="plus-sign"
class="rounded-xl"
@click="openContextMenu"
>
Add filter
</woot-button>
<woot-context-menu
v-if="showContextMenu"
ref="menu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<SLA-context-menu />
</woot-context-menu>
</div>
</template>
<script>
import SLAContextMenu from './SLAContextMenu.vue';
export default {
name: 'SLAFilters',
components: {
SLAContextMenu,
},
props: {},
data() {
return {
showContextMenu: false,
};
},
computed: {},
methods: {
openContextMenu() {
this.showContextMenu = true;
this.contextMenu = {
x: null,
y: null,
};
},
closeContextMenu() {
this.showContextMenu = false;
},
},
};
</script>
@@ -1,66 +0,0 @@
<script setup>
import { computed } from 'vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import CardLabels from 'dashboard/components/widgets/conversation/conversationCardComponents/CardLabels.vue';
const props = defineProps({
slaName: {
type: String,
required: true,
},
conversationId: {
type: Number,
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const assignee = computed(() => props.conversation.assignee);
</script>
<template>
<div
class="grid content-center items-center h-16 grid-cols-12 gap-4 px-6 py-0 w-full bg-white border-b border-slate-75 dark:border-slate-800/50 dark:bg-slate-900"
>
<div
class="flex items-center capitalize py-2 px-0 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 text-left rtl:text-right col-span-2"
>
{{ slaName }}
</div>
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<span class="text-slate-700 dark:text-slate-100">
{{ `#${conversationId} ` }}
</span>
<span class="text-slate-600 dark:text-slate-100">with </span>
<span class="text-slate-700 dark:text-slate-100 capitalize truncate">{{
conversation.contact.name
}}</span>
<card-labels
class="w-[80%]"
:conversation-id="conversationId"
:conversation-labels="conversation.labels"
/>
</div>
<div class="flex items-center gap-2 col-span-2">
<user-avatar-with-name v-if="assignee" :user="assignee" />
<span
v-else
class="text-slate-600 dark:text-slate-200 capitalize truncate"
>
---
</span>
</div>
<div
class="flex items-center col-span-2 px-0 py-2 text-sm tracking-[0.5] text-slate-700 dark:text-slate-100 rtl:text-right"
>
<woot-button color-scheme="secondary" variant="link">
View details
</woot-button>
</div>
</div>
</template>
@@ -1,99 +0,0 @@
<template>
<div>
<div
class="min-w-full border-t border-x rounded-xl border-slate-75 dark:border-slate-800/50"
>
<!-- Header -->
<div
class="grid content-center h-12 grid-cols-12 gap-4 px-6 py-0 border-b bg-slate-25 border-slate-75 dark:border-slate-800 rounded-t-xl dark:bg-slate-900"
>
<div
class="flex items-center font-medium text-xs py-2 px-0 tracking-[10%] text-slate-700 dark:text-slate-100 text-left rtl:text-right uppercase col-span-2"
>
{{ $t('SLA_REPORTS.TABLE.HEADER.POLICY_BREACHED') }}
</div>
<div
class="flex items-center col-span-6 px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('SLA_REPORTS.TABLE.HEADER.CONVERSATION') }}
</div>
<div
class="flex items-center px-0 text-xs font-medium text-left uppercase col-span-2py-2 text-slate-700 dark:text-slate-100 rtl:text-right"
>
{{ $t('SLA_REPORTS.TABLE.HEADER.AGENT') }}
</div>
<div
class="col-span-2 px-0 py-2 text-xs font-medium text-left uppercase text-slate-700 dark:text-slate-100 rtl:text-right"
/>
</div>
<div
v-show="!slaReports.length"
class="flex items-center justify-center h-32 bg-white dark:bg-slate-900"
>
{{ $t('SLA_REPORTS.NO_RECORDS') }}
</div>
<SLA-report-item
v-for="sla in slaReports"
:key="sla.id"
:sla-name="sla.sla_policy.name"
:conversation="sla.conversation"
:conversation-id="sla.conversation_id"
/>
</div>
<table-footer
v-if="shouldShowFooter"
:current-page="currentPage"
:total-count="totalCount"
:page-size="pageSize"
class="dark:bg-slate-900 sticky bottom-0 border-none"
@page-change="onPageChange"
/>
</div>
</template>
<script>
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import SLAReportItem from './SLAReportItem.vue';
export default {
name: 'SLATable',
components: {
SLAReportItem,
TableFooter,
},
props: {
slaReports: {
type: Array,
default: () => [],
},
totalCount: {
type: Number,
default: 0,
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 25,
},
},
data() {
return {
pageNo: 1,
};
},
computed: {
shouldShowFooter() {
return this.currentPage === 1
? this.totalCount > this.pageSize
: this.slaReports.length > 0;
},
},
methods: {
onPageChange(page) {
this.$emit('page-change', page);
},
},
};
</script>
@@ -1,95 +0,0 @@
<template>
<div
:style="{ height: size, width: size }"
class="rounded-full flex items-center justify-center bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
:title="userName"
>
<slot>
<img
v-if="shouldShowImage"
:src="src"
draggable="false"
@load="onImgLoad"
@error="onImgError"
/>
<div
v-else
:style="{ height: size, width: size }"
class="flex justify-center items-center rounded-full"
aria-hidden="true"
>
<span :style="{ fontSize: fontSize }" class="font-semibold">{{
userInitial
}}</span>
</div>
</slot>
</div>
</template>
<script>
import { removeEmoji } from 'shared/helpers/emoji';
export default {
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '24px',
},
userName: {
type: String,
default: '',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.userName);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
userInitial() {
return this.userName.charAt(0).toUpperCase();
},
fontSize() {
return `${Math.floor(this.avatarSize / 2.5)}px`;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
@@ -1,43 +0,0 @@
<template>
<div
class="text-slate-800 dark:text-slate-100 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-white dark:bg-slate-700 justify-between hover:bg-woot-75 cursor-pointer dark:hover:bg-slate-800"
:class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''"
>
<div class="flex items-center">
<p class="my-0 mx-2 text-xs">{{ option.label }}</p>
</div>
<div
v-if="subMenuAvailable"
class="submenu bg-white dark:bg-slate-700 p-1 shadow-lg rounded-md absolute left-full top-0 hidden min-h-min max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
>
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
option: {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
},
};
</script>
<style scoped lang="scss">
.menu-with-submenu {
min-width: calc(6.25rem * 2);
&:hover {
.submenu {
@apply block;
}
}
}
</style>
@@ -9,7 +9,6 @@ const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue');
const BotReports = () => import('./BotReports.vue');
const LiveReports = () => import('./LiveReports.vue');
const SLAReports = () => import('./SLAReports.vue');
export default {
routes: [
@@ -152,22 +151,5 @@ export default {
},
],
},
{
path: frontendURL('accounts/:accountId/reports'),
component: SettingsContent,
props: {
headerTitle: 'SLA_REPORTS.HEADER',
icon: 'document-list-clock',
keepAlive: false,
},
children: [
{
path: 'sla',
name: 'sla_reports',
roles: ['administrator'],
component: SLAReports,
},
],
},
],
};
@@ -4,18 +4,49 @@
:loading-message="$t('SLA.LOADING')"
>
<template #header>
<SLA-header @click="openAddPopup" />
<SLA-header :show-actions="records.length > 0" @click="openAddPopup" />
</template>
<template #loading>
<SLAListItemLoading v-for="ii in 2" :key="ii" class="mb-3" />
</template>
<template #body>
<p
v-if="!records.length"
class="flex flex-col items-center justify-center h-full"
>
{{ $t('SLA.LIST.404') }}
</p>
<div v-if="!records.length" class="w-full min-h-[12rem] relative">
<div class="w-full space-y-3">
<SLA-list-item
class="opacity-25 dark:opacity-20"
:sla-name="$t('SLA.LIST.EMPTY.TITLE_1')"
:description="$t('SLA.LIST.EMPTY.DESC_1')"
first-response="20m"
next-response="1h"
resolution-time="24h"
has-business-hours
/>
<SLA-list-item
class="opacity-25 dark:opacity-20"
:sla-name="$t('SLA.LIST.EMPTY.TITLE_2')"
:description="$t('SLA.LIST.EMPTY.DESC_2')"
first-response="2h"
next-response="4h"
resolution-time="4d"
has-business-hours
/>
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-white dark:from-slate-900 to-transparent"
>
<p class="max-w-xs text-sm font-medium text-center">
{{ $t('SLA.LIST.404') }}
</p>
<woot-button
color-scheme="primary"
icon="plus-sign"
class="px-5 mt-4 rounded-xl"
@click="openAddPopup"
>
{{ $t('SLA.ADD_ACTION_LONG') }}
</woot-button>
</div>
</div>
<div v-if="records.length" class="flex flex-col w-full h-full gap-3">
<SLA-list-item
v-for="sla in records"
@@ -1,5 +1,12 @@
<script setup>
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
defineProps({
showActions: {
type: Boolean,
default: true,
},
});
</script>
<template>
<base-settings-header
@@ -9,7 +16,7 @@ import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
href="/"
icon-name="document-list-clock"
>
<template #actions>
<template v-if="showActions" #actions>
<woot-button
color-scheme="primary"
icon="plus-sign"
@@ -43,7 +43,7 @@ export const actions = {
commit(types.SET_SLA_REPORTS, payload);
commit(types.SET_SLA_REPORTS_META, meta);
} catch (error) {
// Ignore error
throw new Error(error);
} finally {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false });
}
@@ -0,0 +1,78 @@
import axios from 'axios';
import { actions } from '../../sla';
import * as types from '../../../mutation-types';
import SLAList from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: SLAList },
});
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isFetching: true }],
[types.default.SET_SLA, SLAList],
[types.default.SET_SLA_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isFetching: true }],
[types.default.SET_SLA_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({
data: { payload: SLAList[0] },
});
await actions.create({ commit }, SLAList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isCreating: true }],
[types.default.ADD_SLA, SLAList[0]],
[types.default.SET_SLA_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isCreating: true }],
[types.default.SET_SLA_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.delete({ commit }, 1);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isDeleting: true }],
[types.default.DELETE_SLA, 1],
[types.default.SET_SLA_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isDeleting: true }],
[types.default.SET_SLA_UI_FLAG, { isDeleting: false }],
]);
});
});
});
@@ -0,0 +1,95 @@
export default [
{
id: 1,
name: 'Premium SLA',
description:
'SLA for chatwoot cloud premium and self-hosted premium customers. SLA for chatwoot cloud premium and self-hosted premium customers',
first_response_time_threshold: 14400,
next_response_time_threshold: 18000,
resolution_time_threshold: 86400,
only_during_business_hours: true,
},
{
id: 2,
name: 'Enterprise SLA',
description:
'SLA for chatwoot enterprise and self-hosted enterprise customers.',
first_response_time_threshold: 600,
next_response_time_threshold: 2400,
resolution_time_threshold: 3600,
only_during_business_hours: false,
},
{
id: 3,
name: 'Business SLA',
description:
'Chatwoot cloud Business and self-hosted Business customers SLA',
first_response_time_threshold: null,
next_response_time_threshold: null,
resolution_time_threshold: null,
only_during_business_hours: false,
},
{
id: 4,
name: 'Hacker SLA',
description: '',
first_response_time_threshold: 60,
next_response_time_threshold: 120,
resolution_time_threshold: 180,
only_during_business_hours: false,
},
{
id: 5,
name: 'SLA',
description: '',
first_response_time_threshold: 120,
next_response_time_threshold: 300,
resolution_time_threshold: 21600,
only_during_business_hours: false,
},
{
id: 6,
name: 'ALla',
description: '',
first_response_time_threshold: 5400,
next_response_time_threshold: 9000,
resolution_time_threshold: 23040,
only_during_business_hours: false,
},
{
id: 7,
name: '10',
description: '',
first_response_time_threshold: 120,
next_response_time_threshold: null,
resolution_time_threshold: null,
only_during_business_hours: false,
},
{
id: 8,
name: '11',
description: '',
first_response_time_threshold: null,
next_response_time_threshold: 240,
resolution_time_threshold: null,
only_during_business_hours: false,
},
{
id: 9,
name: '12',
description: '',
first_response_time_threshold: null,
next_response_time_threshold: null,
resolution_time_threshold: 300,
only_during_business_hours: false,
},
{
id: 10,
name: '14',
description: '',
first_response_time_threshold: null,
next_response_time_threshold: null,
resolution_time_threshold: null,
only_during_business_hours: false,
},
];
@@ -0,0 +1,26 @@
import { getters } from '../../sla';
import SLAs from './fixtures';
describe('#getters', () => {
it('getSLA', () => {
const state = { records: SLAs };
expect(getters.getSLA(state)).toEqual(SLAs);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
});
});
});
@@ -0,0 +1,34 @@
import types from '../../../mutation-types';
import { mutations } from '../../sla';
import SLAs from './fixtures';
describe('#mutations', () => {
describe('#SET_SLA_UI_FLAG', () => {
it('set sla ui flags', () => {
const state = { uiFlags: {} };
mutations[types.SET_SLA_UI_FLAG](state, { isFetching: true });
expect(state.uiFlags).toEqual({ isFetching: true });
});
});
describe('#SET_SLA', () => {
it('set sla records', () => {
const state = { records: [] };
mutations[types.SET_SLA](state, SLAs);
expect(state.records).toEqual(SLAs);
});
});
describe('#ADD_SLA', () => {
it('push newly created sla to the store', () => {
const state = { records: [SLAs[0]] };
mutations[types.ADD_SLA](state, SLAs[1]);
expect(state.records).toEqual([SLAs[0], SLAs[1]]);
});
});
describe('#DELETE_SLA', () => {
it('delete sla record', () => {
const state = { records: [SLAs[0]] };
mutations[types.DELETE_SLA](state, 1);
expect(state.records).toEqual([]);
});
});
});
@@ -0,0 +1,55 @@
import axios from 'axios';
import { actions } from '../../SLAReports';
import appliedSlas from './fixtures';
import types from '../../../mutation-types';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: appliedSlas, meta: { count: 1 } },
});
await actions.get({ commit }, {});
expect(commit.mock.calls).toEqual([
[types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true }],
[types.SET_SLA_REPORTS, appliedSlas],
[types.SET_SLA_REPORTS_META, { count: 1 }],
[types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.get({ commit }, { teamId: 1 })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true }],
[types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#getMetrics', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: { metrics: { count: 1 } } });
await actions.getMetrics({ commit }, {});
expect(commit.mock.calls).toEqual([
[types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true }],
[types.SET_SLA_REPORTS_METRICS, { metrics: { count: 1 } }],
[types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.getMetrics({ commit }, { teamId: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true }],
[types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false }],
]);
});
});
});
@@ -0,0 +1,52 @@
export default [
{
id: 23,
sla_policy_id: 7,
conversation_id: 152,
sla_status: 'active_with_misses',
created_at: '2024-03-31T07:50:53.518Z',
updated_at: '2024-03-31T07:55:06.451Z',
conversation: {
id: 152,
uuid: '2f9a988d-418f-47d9-b4dc-c441f28da7c2',
account_id: 1,
},
sla_events: [
{
id: 14,
event_type: 'frt',
meta: {},
updated_at: 1711871706,
created_at: 1711871706,
},
{
id: 15,
event_type: 'rt',
meta: {},
updated_at: 1711871706,
created_at: 1711871706,
},
],
},
{
id: 24,
sla_policy_id: 7,
conversation_id: 153,
sla_status: 'active_with_misses',
created_at: '2024-03-31T07:57:49.659Z',
updated_at: '2024-03-31T08:00:31.627Z',
conversation: {
id: 153,
uuid: 'd5d97961-4341-469e-accf-f13f25a14c3c',
},
sla_events: [
{
id: 16,
event_type: 'rt',
meta: {},
updated_at: 1711872031,
created_at: 1711872031,
},
],
},
];
@@ -0,0 +1,24 @@
import { getters } from '../../SLAReports';
import appliedSlas from './fixtures';
describe('#getters', () => {
it('getAppliedSlas', () => {
const state = {
records: [appliedSlas[0]],
};
expect(getters.getAll(state)).toEqual([appliedSlas[0]]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: false,
isFetchingMetrics: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: false,
isFetchingMetrics: false,
});
});
});
@@ -0,0 +1,49 @@
import { mutations } from '../../SLAReports';
import appliedSlas from './fixtures';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#SET_SLA_REPORTS', () => {
it('Adds sla reports', () => {
const state = { records: {} };
mutations[types.SET_SLA_REPORTS](state, appliedSlas);
expect(state.records).toEqual(appliedSlas);
});
});
describe('#SET_SLA_REPORTS_UI_FLAG', () => {
it('set ui flags', () => {
const state = { uiFlags: {} };
mutations[types.SET_SLA_REPORTS_UI_FLAG](state, { isFetching: true });
expect(state.uiFlags).toEqual({ isFetching: true });
});
});
describe('#SET_SLA_REPORTS_METRICS', () => {
it('set metrics', () => {
const state = { metrics: {} };
mutations[types.SET_SLA_REPORTS_METRICS](state, {
number_of_sla_breaches: 1,
hit_rate: '100%',
});
expect(state.metrics).toEqual({
numberOfSLABreaches: 1,
hitRate: '100%',
});
});
});
describe('#SET_SLA_REPORTS_META', () => {
it('set meta', () => {
const state = { meta: {} };
mutations[types.SET_SLA_REPORTS_META](state, {
total_applied_slas: 1,
current_page: 1,
});
expect(state.meta).toEqual({
count: 1,
currentPage: 1,
});
});
});
});
@@ -2,6 +2,7 @@
"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-outline": "M13 12.6V9q0-.425-.288-.713T12 8q-.425 0-.713.288T11 9v3.975q0 .2.075.388t.225.337l2.8 2.8q.275.275.7.275t.7-.275q.275-.275.275-.7t-.275-.7L13 12.6ZM12 22q-1.875 0-3.513-.713t-2.85-1.924q-1.212-1.213-1.924-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-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",
@@ -112,6 +113,7 @@
"error-circle-outline": "M12 2c5.523 0 10 4.478 10 10s-4.477 10-10 10S2 17.522 2 12 6.477 2 12 2Zm0 1.667c-4.595 0-8.333 3.738-8.333 8.333 0 4.595 3.738 8.333 8.333 8.333 4.595 0 8.333-3.738 8.333-8.333 0-4.595-3.738-8.333-8.333-8.333Zm-.001 10.835a.999.999 0 1 1 0 1.998.999.999 0 0 1 0-1.998ZM11.994 7a.75.75 0 0 1 .744.648l.007.101.004 4.502a.75.75 0 0 1-1.493.103l-.007-.102-.004-4.501a.75.75 0 0 1 .75-.751Z",
"file-upload-outline": "M6 2a2 2 0 0 0-2 2v5.207a5.48 5.48 0 0 1 1-.185V4a1 1 0 0 1 1-1h4v3.5A1.5 1.5 0 0 0 11.5 8H15v8a1 1 0 0 1-1 1h-3.6a5.507 5.507 0 0 1-.657 1H14a2 2 0 0 0 2-2V7.414a1.5 1.5 0 0 0-.44-1.06l-3.914-3.915A1.5 1.5 0 0 0 10.586 2H6Zm8.793 5H11.5a.5.5 0 0 1-.5-.5V3.207L14.793 7ZM5.5 19a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9Zm2.354-4.854a.5.5 0 1 1-.708.708L6 13.707V16.5a.5.5 0 0 1-1 0v-2.793l-1.146 1.147a.5.5 0 1 1-.708-.707l2-2A.5.5 0 0 1 5.497 12h.006a.498.498 0 0 1 .348.144l.003.003l2 2Z",
"filter-outline": "M13.5 16a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h3Zm3-5a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1 0-1.5h9Zm3-5a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1 0-1.5h15Z",
"flame-outline": "M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3c-1.072-2.143-.224-4.054 2-6c.5 2.5 2 4.9 4 6.5c2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5",
"flash-on-outline": "m8.294 14-1.767 7.068c-.187.746.736 1.256 1.269.701L19.79 9.27A.75.75 0 0 0 19.25 8h-4.46l1.672-5.013A.75.75 0 0 0 15.75 2h-7a.75.75 0 0 0-.721.544l-3 10.5A.75.75 0 0 0 5.75 14h2.544Zm4.745-5.487a.75.75 0 0 0 .711.987h3.74l-8.824 9.196 1.316-5.264a.75.75 0 0 0-.727-.932h-2.51l2.57-9h5.394l-1.67 5.013Z",
"flash-settings-outline": "M6.19 2.77c.13-.455.547-.77 1.02-.77h5.25c.724 0 1.236.71 1.007 1.398l-.002.008L12.204 7h2.564c.946 0 1.407 1.144.766 1.811l-.003.004l-.237.242a5.545 5.545 0 0 0-1.374-.027l.894-.912a.056.056 0 0 0 .017-.032a.084.084 0 0 0-.007-.044a.079.079 0 0 0-.025-.034c-.005-.004-.013-.008-.031-.008h-3.27a.5.5 0 0 1-.471-.666L12.52 3.08a.062.062 0 0 0-.06-.08H7.211a.062.062 0 0 0-.06.045l-2.25 7.874c-.01.04.019.08.06.08H6.87a.5.5 0 0 1 .485.62l-1.325 5.3a.086.086 0 0 0-.003.03a.02.02 0 0 0 .003.011a.08.08 0 0 0 .072.04a.03.03 0 0 0 .01-.004a.087.087 0 0 0 .024-.018l.003-.004l2.882-2.94a5.573 5.573 0 0 0 .054 1.372l-2.22 2.267c-.754.782-2.059.06-1.795-.996l1.17-4.679H4.96a1.062 1.062 0 0 1-1.021-1.354l2.25-7.873Zm5.877 8.673a2 2 0 0 1-1.431 2.478l-.461.118a4.702 4.702 0 0 0 .01 1.016l.35.083a2 2 0 0 1 1.456 2.519l-.127.422c.257.204.537.378.835.518l.325-.344a2 2 0 0 1 2.91.002l.337.358c.292-.135.568-.302.822-.498l-.157-.556a2 2 0 0 1 1.431-2.479l.46-.117a4.702 4.702 0 0 0-.01-1.017l-.348-.082a2 2 0 0 1-1.456-2.52l.126-.421a4.32 4.32 0 0 0-.835-.519l-.325.344a2 2 0 0 1-2.91-.001l-.337-.358a4.316 4.316 0 0 0-.821.497l.156.557ZM14.5 15.5a1 1 0 1 1 0-2a1 1 0 0 1 0 2Z",
"folder-outline": "M8.207 4c.46 0 .908.141 1.284.402l.156.12L12.022 6.5h7.728a2.25 2.25 0 0 1 2.229 1.938l.016.158.005.154v9a2.25 2.25 0 0 1-2.096 2.245L19.75 20H4.25a2.25 2.25 0 0 1-2.245-2.096L2 17.75V6.25a2.25 2.25 0 0 1 2.096-2.245L4.25 4h3.957Zm1.44 5.979a2.25 2.25 0 0 1-1.244.512l-.196.009-4.707-.001v7.251c0 .38.282.694.648.743l.102.007h15.5a.75.75 0 0 0 .743-.648l.007-.102v-9a.75.75 0 0 0-.648-.743L19.75 8h-7.729L9.647 9.979ZM8.207 5.5H4.25a.75.75 0 0 0-.743.648L3.5 6.25v2.749L8.207 9a.75.75 0 0 0 .395-.113l.085-.06 1.891-1.578-1.89-1.575a.75.75 0 0 0-.377-.167L8.207 5.5Z",
-5
View File
@@ -33,11 +33,6 @@ class NotificationListener < BaseListener
return if event.data[:notifiable_assignee_change].blank?
return if conversation.pending?
if assignee.nil?
Rail.logger.error "[NotificationListener] Assignee is nil, skipping notification creation for #{conversation.id}"
return
end
NotificationBuilder.new(
notification_type: 'conversation_assignment',
user: assignee,
@@ -65,3 +65,5 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
})
end
end
AgentNotifications::ConversationNotificationsMailer.prepend_mod_with('AgentNotifications::ConversationNotificationsMailer')
+6 -2
View File
@@ -5,11 +5,13 @@ class ApplicationRecord < ActiveRecord::Base
before_validation :validates_column_content_length
# the models that exposed in email templates through liquid
DROPPABLES = %w[Account Channel Conversation Inbox User Message].freeze
def droppables
%w[Account Channel Conversation Inbox User Message]
end
# ModelDrop class should exist in app/drops
def to_drop
return unless DROPPABLES.include?(self.class.name)
return unless droppables.include?(self.class.name)
"#{self.class.name}Drop".constantize.new(self)
end
@@ -47,3 +49,5 @@ class ApplicationRecord < ActiveRecord::Base
end
end
end
ApplicationRecord.prepend_mod_with('ApplicationRecord')
+15
View File
@@ -0,0 +1,15 @@
module PushDataHelper
extend ActiveSupport::Concern
def push_event_data
Conversations::EventDataPresenter.new(self).push_data
end
def lock_event_data
Conversations::EventDataPresenter.new(self).lock_data
end
def webhook_data
Conversations::EventDataPresenter.new(self).push_data
end
end
+14 -16
View File
@@ -57,6 +57,7 @@ class Conversation < ApplicationRecord
include ActivityMessageHandler
include UrlHelper
include SortHandler
include PushDataHelper
include ConversationMuteHelpers
validates :account_id, presence: true
@@ -171,18 +172,6 @@ class Conversation < ApplicationRecord
unread_messages.where(account_id: account_id).incoming.last(10)
end
def push_event_data
Conversations::EventDataPresenter.new(self).push_data
end
def lock_event_data
Conversations::EventDataPresenter.new(self).lock_data
end
def webhook_data
Conversations::EventDataPresenter.new(self).push_data
end
def cached_label_list_array
(cached_label_list || '').split(',').map(&:strip)
end
@@ -207,6 +196,10 @@ class Conversation < ApplicationRecord
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{uuid}"
end
def dispatch_conversation_updated_event(previous_changes = nil)
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end
private
def execute_after_update_commit_callbacks
@@ -245,13 +238,17 @@ class Conversation < ApplicationRecord
def notify_conversation_updation
return unless previous_changes.keys.present? && allowed_keys?
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
dispatch_conversation_updated_event(previous_changes)
end
def list_of_keys
%w[team_id assignee_id status snoozed_until custom_attributes label_list waiting_since first_reply_created_at
priority]
end
def allowed_keys?
(
previous_changes.keys.intersect?(%w[team_id assignee_id status snoozed_until custom_attributes label_list waiting_since first_reply_created_at
priority]) ||
previous_changes.keys.intersect?(list_of_keys) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
)
end
@@ -312,5 +309,6 @@ class Conversation < ApplicationRecord
end
end
Conversation.include_mod_with('EnterpriseConversationConcern')
Conversation.include_mod_with('Concerns::Conversation')
Conversation.include_mod_with('SentimentAnalysisHelper')
Conversation.prepend_mod_with('Conversation')
+13 -3
View File
@@ -118,11 +118,11 @@ class Notification < ApplicationRecord
def push_message_body
case notification_type
when 'conversation_creation'
when 'conversation_creation', 'sla_missed_first_response'
message_body(conversation.messages.first)
when 'assigned_conversation_new_message', 'participating_conversation_new_message', 'conversation_mention'
message_body(secondary_actor)
when 'conversation_assignment'
when 'conversation_assignment', 'sla_missed_next_response', 'sla_missed_resolution'
message_body(conversation.messages.incoming.last)
else
''
@@ -162,11 +162,21 @@ class Notification < ApplicationRecord
# Should we do something about the case where user subscribed to both push and email ?
# In future, we could probably add condition here to enqueue the job for 30 seconds later
# when push enabled and then check in email job whether notification has been read already.
Notification::EmailNotificationJob.perform_later(self) if user_subscribed_to_notification?('email')
Notification::EmailNotificationJob.perform_later(self) if should_send_email? && user_subscribed_to_notification?('email')
Notification::RemoveDuplicateNotificationJob.perform_later(self)
end
def should_send_email?
case notification_type
when 'sla_missed_first_response', 'sla_missed_next_response', 'sla_missed_resolution'
# we send an email digest for this anyway so no need to send individual emails
false
else
true
end
end
def user_subscribed_to_notification?(delivery_type)
notification_setting = user.notification_settings.find_by(account_id: account.id)
return false if notification_setting.blank?
@@ -45,3 +45,4 @@ class Conversations::EventDataPresenter < SimpleDelegator
}
end
end
Conversations::EventDataPresenter.prepend_mod_with('Conversations::EventDataPresenter')
@@ -1 +1 @@
json.partial! 'api/v1/models/agent', formats: [:json], resource: @user
json.partial! 'api/v1/models/agent', formats: [:json], resource: @agent
@@ -1,3 +1,7 @@
# TODO: Move this into models jbuilder
# Currently the file there is used only for search endpoint.
# Everywhere else we use conversation builder in partials folder
json.meta do
json.sender do
json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact
@@ -48,3 +52,4 @@ 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
json.partial! 'enterprise/api/v1/conversations/partials/conversation', conversation: conversation if ChatwootApp.enterprise?
@@ -1,3 +1,5 @@
# This file is used to render conversation data search API response.
json.id conversation.display_id
json.uuid conversation.uuid
json.created_at conversation.created_at.to_i
@@ -0,0 +1,51 @@
<p>Hello {{ user.available_name }},</p>
<p>This is a digest of the missed SLAs in your conversations:</p>
{% for conversation, missed_slas in missed_slas_by_conversation %}
<div style='margin-bottom: 20px;'>
<h3>Conversation: {{ conversation.display_id }}</h3>
<table style='width: 100%; border-collapse: collapse;'>
<thead>
<tr>
<th style='text-align: left; border-bottom: 1px solid #ccc; padding: 5px;'>
SLA Policy
</th>
<th style='text-align: left; border-bottom: 1px solid #ccc; padding: 5px;'>
Missed Event
</th>
<th style='text-align: left; border-bottom: 1px solid #ccc; padding: 5px;'>
Missed At
</th>
</tr>
</thead>
<tbody>
{% for missed_sla in missed_slas %}
{% for sla_event in missed_sla.sla_events %}
<tr>
<td style='padding: 5px;'>
{{ missed_sla.applied_sla.sla_policy.name }}
</td>
<td style='padding: 5px;'>{{ sla_event.event }}</td>
<td style='padding: 5px;'>
{{ sla_event.created_at | date: '%b %d, %Y %I:%M %p' }}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
<p>
<a href='{{ conversation_url }}' target='_blank'>View Conversation</a>
</p>
</div>
{% endfor %}
<p>
Please take the necessary actions to address these missed SLAs and ensure
timely responses to the conversations.
</p>
<p>
Best regards,<br>
Your SLA Monitoring System
</p>
@@ -0,0 +1,10 @@
<p>Hi {{user.available_name}},</p>
<p>
Conversation #{{conversation.display_id}} in {{ inbox.name }}
has missed the SLA for first response under policy {{ sla_policy.name }}.
</p>
<p>
<a href="{{action_url}}">Please address immediately.</a>
</p>
@@ -0,0 +1,10 @@
<p>Hi {{user.available_name}},</p>
<p>
Conversation #{{conversation.display_id}} in {{ inbox.name }}
has missed the SLA for next response under policy {{ sla_policy.name }}..
</p>
<p>
<a href="{{action_url}}">Please address immediately.</a>
</p>
@@ -0,0 +1,10 @@
<p>Hi {{user.available_name}},</p>
<p>
Conversation #{{conversation.display_id}} in {{ inbox.name }}
has missed the SLA for resolution time under policy {{ sla_policy.name }}.
</p>
<p>
<a href="{{action_url}}">Please address immediately.</a>
</p>
@@ -6,14 +6,12 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
before_action :set_applied_slas, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index]
before_action :paginate_slas, only: [:index]
before_action :check_admin_authorization?
sort_on :created_at, type: :datetime
def index
@total_applied_slas = total_applied_slas
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def index; end
def metrics
@total_applied_slas = total_applied_slas
@@ -54,7 +52,7 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
end
def set_applied_slas
initial_query = Current.account.applied_slas.includes(:conversation).includes(:sla_policy)
initial_query = Current.account.applied_slas.includes(:conversation)
@applied_slas = initial_query
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
@@ -64,6 +62,10 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
.filter_by_assigned_agent_id(params[:assigned_agent_id])
end
def paginate_slas
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
end
def set_current_page
@current_page = params[:page] || 1
end
@@ -1,8 +1,2 @@
class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController
before_action :prepend_view_paths
# Prepend the view path to the enterprise/app/views won't be available by default
def prepend_view_paths
prepend_view_path 'enterprise/app/views/'
end
end
@@ -0,0 +1,12 @@
module Enterprise::Concerns::ApplicationControllerConcern
extend ActiveSupport::Concern
included do
before_action :prepend_view_paths
end
# Prepend the view path to the enterprise/app/views won't be available by default
def prepend_view_paths
prepend_view_path 'enterprise/app/views/'
end
end
+9
View File
@@ -0,0 +1,9 @@
class SlaPolicyDrop < BaseDrop
def name
@obj.try(:name)
end
def description
@obj.try(:description)
end
end
@@ -0,0 +1,5 @@
module Enterprise::ConversationFinder
def conversations_base_query
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
end
@@ -2,8 +2,39 @@ class Sla::ProcessAccountAppliedSlasJob < ApplicationJob
queue_as :medium
def perform(account)
account.applied_slas.where(sla_status: %w[active active_with_misses]).each do |applied_sla|
Sla::ProcessAppliedSlaJob.perform_later(applied_sla)
missed_slas = []
account.applied_slas.where(sla_status: %w[active active_with_misses]).includes(:conversation, :sla_policy).each do |applied_sla|
sla_events, updated_applied_sla = Sla::EvaluateAppliedSlaService.new(applied_sla: applied_sla).perform
if updated_applied_sla.status == :missed || updated_applied_sla.status == :active_with_misses
missed_slas << { sla_events: sla_events,
applied_sla: updated_applied_sla }
end
end
user_sla_map = build_user_sla_map(missed_slas)
notify_users(user_sla_map)
end
def build_user_sla_map(missed_slas)
missed_slas.each_with_object({}) do |missed_sla, user_missed_slas_map|
applied_sla = missed_sla[:applied_sla]
sla_events = missed_sla[:sla_events]
conversation = applied_sla.conversation
users_to_notify = conversation.conversation_participants.map(&:user)
users_to_notify << conversation.assignee if conversation.assignee.present?
users_to_notify.uniq.each do |user|
user_missed_slas_map[user] ||= []
user_missed_slas_map[user] += [{ sla_events: sla_events, applied_sla: applied_sla }]
end
end
end
def notify_users(user_sla_map)
user_sla_map.each do |user, sla_data|
puts "Notifying user #{user.id} and #{admins.count} admins about #{sla_data.count} missed SLAs"
end
end
end
@@ -1,7 +0,0 @@
class Sla::ProcessAppliedSlaJob < ApplicationJob
queue_as :medium
def perform(applied_sla)
Sla::EvaluateAppliedSlaService.new(applied_sla: applied_sla).perform
end
end
@@ -0,0 +1,30 @@
module Enterprise::AgentNotifications::ConversationNotificationsMailer
def missed_slas_digest(user, missed_slas)
return unless smtp_config_set_or_development?
@user = user
@missed_slas = missed_slas
subject = 'Missed SLAs Digest'
# Group missed SLAs by conversation using each_with_object
@missed_slas_by_conversation = @missed_slas.each_with_object({}) do |missed_sla, result|
applied_sla = missed_sla[:applied_sla]
sla_events = missed_sla[:sla_events]
conversation = applied_sla.conversation
result[conversation] ||= []
result[conversation] << {
sla_events: sla_events,
applied_sla: applied_sla
}
end
send_mail_with_liquid(to: @user.email, subject: subject) and return
end
def liquid_droppables
super.merge({
missed_slas_by_conversation: @missed_slas_by_conversation
})
end
end
+30 -1
View File
@@ -39,9 +39,38 @@ class AppliedSla < ApplicationRecord
joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id })
end
}
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
scope :missed, -> { where(sla_status: :missed) }
after_update_commit :push_conversation_event
def push_event_data
{
id: id,
sla_id: sla_policy_id,
sla_status: sla_status,
created_at: created_at.to_i,
updated_at: updated_at.to_i,
sla_description: sla_policy.description,
sla_name: sla_policy.name,
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
sla_next_response_time_threshold: sla_policy.next_response_time_threshold,
sla_only_during_business_hours: sla_policy.only_during_business_hours,
sla_resolution_time_threshold: sla_policy.resolution_time_threshold
}
end
private
def push_conversation_event
# right now we simply use `CONVERSATION_UPDATED` event to notify the frontend
# we can eventually start using `CONVERSATION_SLA_UPDATED` event as required later
# for now the updated event should suffice
return unless saved_change_to_sla_status?
conversation.dispatch_conversation_updated_event
end
def ensure_account_id
self.account_id ||= sla_policy&.account_id
end
@@ -0,0 +1,5 @@
module Enterprise::ApplicationRecord
def droppables
super + %w[SlaPolicy]
end
end
@@ -1,9 +1,10 @@
module Enterprise::EnterpriseConversationConcern
module Enterprise::Concerns::Conversation
extend ActiveSupport::Concern
included do
belongs_to :sla_policy, optional: true
has_one :applied_sla, dependent: :destroy
has_one :applied_sla, dependent: :destroy_async
has_many :sla_events, dependent: :destroy_async
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
end
@@ -0,0 +1,5 @@
module Enterprise::Conversation
def list_of_keys
super + %w[sla_policy_id]
end
end
+10 -1
View File
@@ -31,9 +31,18 @@ class SlaEvent < ApplicationRecord
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
after_create_commit :create_notifications
def push_event_data
{
id: id,
event_type: event_type,
meta: meta,
created_at: created_at.to_i,
updated_at: updated_at.to_i
}
end
private
def ensure_applied_sla_id
@@ -0,0 +1,13 @@
module Enterprise::Conversations::EventDataPresenter
def push_data
if account.feature_enabled?('sla')
super.merge(
applied_sla: applied_sla&.push_event_data,
sla_events: sla_events.map(&:push_event_data),
sla_policy_id: sla_policy_id
)
else
super
end
end
end
@@ -1,23 +1,32 @@
class Sla::EvaluateAppliedSlaService
pattr_initialize [:applied_sla!]
FRT_THRESHOLD = 'frt'.freeze
NRT_THRESHOLD = 'nrt'.freeze
RT_THRESHOLD = 'rt'.freeze
def perform
check_sla_thresholds
sla_events = []
check_sla_thresholds(sla_events)
# We will calculate again in the next iteration
return unless applied_sla.conversation.resolved?
return [sla_events, applied_sla] unless applied_sla.conversation.resolved?
# after conversation is resolved, we will check if the SLA was hit or missed
handle_hit_sla(applied_sla)
[sla_events, applied_sla]
end
private
def check_sla_thresholds
def check_sla_thresholds(sla_events)
[:first_response_time_threshold, :next_response_time_threshold, :resolution_time_threshold].each do |threshold|
next if applied_sla.sla_policy.send(threshold).blank?
send("check_#{threshold}", applied_sla, applied_sla.conversation, applied_sla.sla_policy)
event = send("check_#{threshold}", applied_sla, applied_sla.conversation, applied_sla.sla_policy)
sla_events << event if event.present?
end
end
@@ -30,7 +39,7 @@ class Sla::EvaluateAppliedSlaService
return if first_reply_was_within_threshold?(conversation, threshold)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'frt')
handle_missed_sla(applied_sla, FRT_THRESHOLD)
end
def first_reply_was_within_threshold?(conversation, threshold)
@@ -46,10 +55,11 @@ class Sla::EvaluateAppliedSlaService
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'nrt')
handle_missed_sla(applied_sla, NRT_THRESHOLD)
end
def get_last_message_id(conversation)
# TODO: refactor the method to fetch last message without reply
conversation.messages.where(message_type: :incoming).last&.id
end
@@ -63,11 +73,11 @@ class Sla::EvaluateAppliedSlaService
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'rt')
handle_missed_sla(applied_sla, RT_THRESHOLD)
end
def handle_missed_sla(applied_sla, type, meta = {})
meta = { message_id: get_last_message_id(applied_sla.conversation) } if type == 'nrt'
meta = { message_id: get_last_message_id(applied_sla.conversation) } if type == NRT_THRESHOLD
return if already_missed?(applied_sla, type, meta)
create_sla_event(applied_sla, type, meta)
@@ -1,28 +1,14 @@
json.payload do
json.array! @applied_slas do |applied_sla|
json.id applied_sla.id
json.sla_policy_id applied_sla.sla_policy_id
json.conversation_id applied_sla.conversation_id
json.sla_status applied_sla.sla_status
json.created_at applied_sla.created_at
json.updated_at applied_sla.updated_at
json.conversation do
json.contact do
json.name applied_sla.conversation.contact.name
end
json.labels applied_sla.conversation.cached_label_list
json.assignee applied_sla.conversation.assignee
end
json.sla_events applied_sla.sla_events do |sla_event|
json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event
end
json.sla_policy do
json.partial! 'api/v1/models/sla_policy', sla_policy: applied_sla.sla_policy
end
json.array! @applied_slas do |applied_sla|
json.id applied_sla.id
json.sla_policy_id applied_sla.sla_policy_id
json.conversation_id applied_sla.conversation_id
json.sla_status applied_sla.sla_status
json.created_at applied_sla.created_at
json.updated_at applied_sla.updated_at
json.conversation do
json.partial! 'api/v1/models/conversation', conversation: applied_sla.conversation
end
json.sla_events applied_sla.sla_events do |sla_event|
json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event
end
end
json.meta do
json.total_applied_slas @total_applied_slas
json.current_page @current_page
end
@@ -0,0 +1,11 @@
json.id resource.id
json.sla_id resource.sla_policy_id
json.sla_status resource.sla_status
json.created_at resource.created_at.to_i
json.updated_at resource.updated_at.to_i
json.sla_description resource.sla_policy.description
json.sla_name resource.sla_policy.name
json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold
json.sla_next_response_time_threshold resource.sla_policy.next_response_time_threshold
json.sla_only_during_business_hours resource.sla_policy.only_during_business_hours
json.sla_resolution_time_threshold resource.sla_policy.resolution_time_threshold
@@ -0,0 +1,10 @@
if conversation.account.feature_enabled?('sla')
json.applied_sla do
json.partial! 'api/v1/models/applied_sla', formats: [:json], resource: conversation.applied_sla if conversation.applied_sla.present?
end
json.sla_events do
json.array! conversation.sla_events do |sla_event|
json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event
end
end
end
+6
View File
@@ -44,6 +44,12 @@ conversations:
- "not_equal_to"
- "is_present"
- "is_not_present"
priority:
attribute_type: "standard"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
display_id:
attribute_type: "standard"
data_type: "Number"
@@ -16,8 +16,8 @@ class Integrations::Slack::IncomingMessageBuilder
if hook_verification?
verify_hook
elsif create_message?
create_message
elsif process_message_payload?
process_message_payload
elsif link_shared?
SlackUnfurlJob.perform_later(params)
end
@@ -67,7 +67,7 @@ class Integrations::Slack::IncomingMessageBuilder
params[:event][:thread_ts].present?
end
def create_message?
def process_message_payload?
thread_timestamp_available? && supported_message? && integration_hook
end
+17 -5
View File
@@ -1,21 +1,32 @@
module Integrations::Slack::SlackMessageHelper
def create_message
def process_message_payload
return unless conversation
build_message
@message.save!
{ status: 'success' }
handle_conversation
success_response
rescue Slack::Web::Api::Errors::MissingScope => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
disable_and_reauthorize
end
def handle_conversation
create_message unless message_exists?
end
def success_response
{ status: 'success' }
end
def disable_and_reauthorize
integration_hook.prompt_reauthorization!
integration_hook.disable
end
def build_message
def message_exists?
conversation.messages.exists?(external_source_ids: { slack: params[:event][:ts] })
end
def create_message
@message = conversation.messages.build(
message_type: :outgoing,
account_id: conversation.account_id,
@@ -26,6 +37,7 @@ module Integrations::Slack::SlackMessageHelper
sender: sender
)
process_attachments(params[:event][:files]) if attachments_present?
@message.save!
end
def attachments_present?
@@ -58,5 +58,91 @@ describe Messages::Facebook::MessageBuilder do
expect(facebook_channel.inbox.reload.contacts.count).to eq(1)
expect(contact.name).to eq(default_name)
end
context 'when lock to single conversation' do
subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
end
let!(:mocked_message_object) { build(:mocked_message_text, sender_id: contact_inbox.source_id).to_json }
let!(:mocked_incoming_fb_text_message) { Integrations::Facebook::MessageParser.new(mocked_message_object) }
let(:contact) { create(:contact, name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: facebook_channel.inbox.id) }
context 'when lock to single conversation is disabled' do
before do
facebook_channel.inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
contact_id: contact.id, contact_inbox_id: contact_inbox.id,
status: :open)
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
contact_id: contact.id, contact_inbox_id: contact_inbox.id, status: :resolved)
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
facebook_channel.inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation exists' do
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
contact_id: contact.id, contact_inbox_id: contact_inbox.id)
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
end
end
end
@@ -183,4 +183,96 @@ describe Messages::Instagram::MessageBuilder do
expect(contact.name).to eq('Jane Dae')
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id, status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
contact_inbox
described_class.new(message, instagram_inbox).perform
instagram_inbox.reload
contact_inbox.reload
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
end
@@ -171,6 +171,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['email']).to eq(params[:email])
expect(account.users.last.name).to eq('NewUser')
end
end
@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe 'Conversations API', type: :request do
let(:account) { create(:account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
describe 'GET /api/v1/accounts/{account.id}/conversations/:id' do
it 'returns SLA data for the conversation if the feature is enabled' do
account.enable_features!('sla')
conversation = create(:conversation, account: account)
applied_sla = create(:applied_sla, conversation: conversation)
sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id)
expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id)
end
it 'does not return SLA data for the conversation if the feature is disabled' do
account.disable_features!('sla')
conversation = create(:conversation, account: account)
create(:applied_sla, conversation: conversation)
create(:sla_event, conversation: conversation)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body.keys).not_to include('applied_sla')
expect(response.parsed_body.keys).not_to include('sla_events')
end
end
end
@@ -0,0 +1,15 @@
require 'rails_helper'
describe SlaPolicyDrop do
subject(:sla_policy_drop) { described_class.new(sla_policy) }
let!(:sla_policy) { create(:sla_policy) }
it 'returns name' do
expect(sla_policy_drop.name).to eq sla_policy.name
end
it 'returns description' do
expect(sla_policy_drop.description).to eq sla_policy.description
end
end
@@ -14,15 +14,15 @@ RSpec.describe Sla::ProcessAccountAppliedSlasJob do
.on_queue('medium')
end
it 'calls the ProcessAppliedSlaJob for both active and active_with_misses' do
expect(Sla::ProcessAppliedSlaJob).to receive(:perform_later).with(active_with_misses_applied_sla).and_call_original
expect(Sla::ProcessAppliedSlaJob).to receive(:perform_later).with(applied_sla).and_call_original
it 'calls the EvaluateAppliedSlaService for both active and active_with_misses' do
expect(Sla::EvaluateAppliedSlaService).to receive(:perform).with(active_with_misses_applied_sla).and_call_original
expect(Sla::EvaluateAppliedSlaService).to receive(:perform).with(applied_sla).and_call_original
described_class.perform_now(account)
end
it 'does not call the ProcessAppliedSlaJob for applied slas that are hit or miss' do
expect(Sla::ProcessAppliedSlaJob).not_to receive(:perform_later).with(hit_applied_sla)
expect(Sla::ProcessAppliedSlaJob).not_to receive(:perform_later).with(miss_applied_sla)
it 'does not call the EvaluateAppliedSlaService for applied slas that are hit or miss' do
expect(Sla::EvaluateAppliedSlaService).not_to receive(:perform).with(hit_applied_sla)
expect(Sla::EvaluateAppliedSlaService).not_to receive(:perform).with(miss_applied_sla)
described_class.perform_now(account)
end
end
@@ -1,18 +0,0 @@
require 'rails_helper'
RSpec.describe Sla::ProcessAppliedSlaJob do
context 'when perform is called' do
let(:account) { create(:account) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('medium')
end
it 'calls the EvaluateAppliedSlaService' do
applied_sla = create(:applied_sla)
expect(Sla::EvaluateAppliedSlaService).to receive(:new).with(applied_sla: applied_sla).and_call_original
described_class.perform_now(applied_sla)
end
end
end
@@ -0,0 +1,54 @@
require 'rails_helper'
# rails helper is using infer filetype to detect rspec type
# so we need to include type: :mailer to make this test work in enterprise namespace
RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :mailer do
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let(:conversation) { create(:conversation, assignee: agent, account: account) }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end
describe 'sla_missed_first_response' do
let(:sla_policy) { create(:sla_policy, account: account) }
let(:mail) { described_class.with(account: account).sla_missed_first_response(conversation, agent, sla_policy).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for first response")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
end
describe 'sla_missed_next_response' do
let(:sla_policy) { create(:sla_policy, account: account) }
let(:mail) { described_class.with(account: account).sla_missed_next_response(conversation, agent, sla_policy).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for next response")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
end
describe 'sla_missed_resolution' do
let(:sla_policy) { create(:sla_policy, account: account) }
let(:mail) { described_class.with(account: account).sla_missed_resolution(conversation, agent, sla_policy).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for resolution time")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
end
end
@@ -7,6 +7,27 @@ RSpec.describe AppliedSla, type: :model do
it { is_expected.to belong_to(:conversation) }
end
describe 'push_event_data' do
it 'returns the correct hash' do
applied_sla = create(:applied_sla)
expect(applied_sla.push_event_data).to eq(
{
id: applied_sla.id,
sla_id: applied_sla.sla_policy_id,
sla_status: applied_sla.sla_status,
created_at: applied_sla.created_at.to_i,
updated_at: applied_sla.updated_at.to_i,
sla_description: applied_sla.sla_policy.description,
sla_name: applied_sla.sla_policy.name,
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
sla_next_response_time_threshold: applied_sla.sla_policy.next_response_time_threshold,
sla_only_during_business_hours: applied_sla.sla_policy.only_during_business_hours,
sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold
}
)
end
end
describe 'validates_factory' do
it 'creates valid applied sla policy object' do
applied_sla = create(:applied_sla)
+15
View File
@@ -9,6 +9,21 @@ RSpec.describe SlaEvent, type: :model do
it { is_expected.to belong_to(:inbox) }
end
describe 'push_event_data' do
it 'returns the correct hash' do
sla_event = create(:sla_event)
expect(sla_event.push_event_data).to eq(
{
id: sla_event.id,
event_type: 'frt',
meta: sla_event.meta,
created_at: sla_event.created_at.to_i,
updated_at: sla_event.updated_at.to_i
}
)
end
end
describe 'validates_factory' do
it 'creates valid sla event object' do
sla_event = create(:sla_event)
@@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Conversations::EventDataPresenter do
let!(:presenter) { described_class.new(conversation) }
let!(:conversation) { create(:conversation) }
let!(:applied_sla) { create(:applied_sla, conversation: conversation) }
let!(:sla_event) { create(:sla_event, conversation: conversation, applied_sla: applied_sla) }
describe '#push_data' do
it 'returns push event payload with applied sla & sla events if the feature is enabled' do
conversation.account.enable_features!('sla')
expect(presenter.push_data).to include(
{
applied_sla: applied_sla.push_event_data,
sla_events: [sla_event.push_event_data]
}
)
end
it 'returns push event payload without applied sla & sla events if the feature is disabled' do
conversation.account.disable_features!('sla')
expect(presenter.push_data).not_to include(:applied_sla, :sla_events)
end
end
end
@@ -11,6 +11,21 @@ FactoryBot.define do
initialize_with { attributes }
end
factory :mocked_message_text, class: Hash do
transient do
sender_id { '3383290475046708' }
end
initialize_with do
{ messaging: { sender: { id: sender_id },
recipient: { id: '117172741761305' },
message: { mid: 'm_KXGKDUpO6xbVdAmZFBVpzU1AhKVJdAIUnUH4cwkvb_K3iZsWhowDRyJ_DcowEpJjncaBwdCIoRrixvCbbO1PcA',
text: 'facebook message' } } }
end
# initialize_with { attributes }
end
factory :message_deliveries, class: Hash do
messaging do
{ sender: { id: '3383290475046708' },
@@ -65,6 +65,16 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.count).to eql(messages_count)
end
it 'does not create message if message already exists' do
expect(hook).not_to be_nil
messages_count = conversation.messages.count
builder = described_class.new(message_params)
allow(builder).to receive(:sender).and_return(nil)
2.times.each { builder.perform }
expect(conversation.messages.count).to eql(messages_count + 1)
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
end
it 'creates message' do
expect(hook).not_to be_nil
messages_count = conversation.messages.count
@@ -23,7 +23,7 @@ describe AutomationRuleListener do
attribute_display_type: 'list',
attribute_values: %w[regular platinum gold])
create(:custom_attribute_definition,
attribute_key: 'priority',
attribute_key: 'priority_level',
account: account,
attribute_model: 'conversation_attribute',
attribute_display_type: 'list',
@@ -267,7 +267,7 @@ describe AutomationRuleListener do
context 'when rule matches based on custom_attributes' do
before do
conversation.update!(custom_attributes: { priority: 'P2' })
conversation.update!(custom_attributes: { priority_level: 'P2' })
conversation.contact.update!(custom_attributes: { cloud_customer: false })
automation_rule.update!(
@@ -276,7 +276,7 @@ describe AutomationRuleListener do
description: 'Add labels, assign team after conversation updated',
conditions: [
{
attribute_key: 'priority',
attribute_key: 'priority_level',
filter_operator: 'equal_to',
values: ['P2'],
custom_attribute_type: 'conversation_attribute',
@@ -38,7 +38,8 @@ RSpec.describe Conversations::EventDataPresenter do
end
it 'returns push event payload' do
expect(presenter.push_data).to eq(expected_data)
# the exceptions are the values that would be added in enterprise edition.
expect(presenter.push_data.except(:applied_sla, :sla_events)).to include(expected_data)
end
end
end