Merge branch 'develop' into extending-lock-to-single-conversation-to-meta-inbox

This commit is contained in:
Jaideep Guntupalli
2024-04-02 17:59:47 +05:30
committed by GitHub
71 changed files with 1506 additions and 272 deletions
-6
View File
@@ -16,7 +16,6 @@ class AgentBuilder
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
send_confirmation_if_required
create_account_user
end
@user
@@ -34,11 +33,6 @@ class AgentBuilder
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end
# Sends confirmation instructions if the user is persisted and not confirmed.
def send_confirmation_if_required
@user.send_confirmation_instructions if user_needs_confirmation?
end
# Checks if the user needs confirmation.
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
def user_needs_confirmation?
@@ -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')
@@ -0,0 +1,103 @@
<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'"
>
<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'
"
>
<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>
</template>
<script>
import { mapGetters } from 'vuex';
import { evaluateSLAStatus } from '../helpers/SLAHelper';
// const REFRESH_INTERVAL = 60000;
export default {
props: {
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
},
data() {
return {
timer: null,
slaStatus: {},
};
},
computed: {
...mapGetters({
activeSLA: 'sla/getSLAById',
}),
slaPolicyId() {
return this.chat?.sla_policy_id;
},
sla() {
if (!this.slaPolicyId) return null;
return this.activeSLA(this.slaPolicyId);
},
isSlaMissed() {
return this.slaStatus?.isSlaMissed;
},
slaTextStyles() {
return this.isSlaMissed
? 'text-red-400 dark:text-red-300'
: 'text-yellow-600 dark:text-yellow-500';
},
slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'BREACH' : 'DUE';
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
},
},
watch: {
chat() {
this.updateSlaStatus();
},
},
mounted() {
this.updateSlaStatus();
},
methods: {
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.sla, this.chat);
},
},
};
</script>
@@ -0,0 +1,41 @@
import { debounce } from '@chatwoot/utils';
const RESIZE_OBSERVER_DEBOUNCE_TIME = 100;
function createResizeObserver(el, binding) {
const { value } = binding;
const observer = new ResizeObserver(
debounce(entries => {
const entry = entries[0];
if (entry && value && typeof value === 'function') {
value(entry);
}
}, RESIZE_OBSERVER_DEBOUNCE_TIME)
);
el.cwResizeObserver = observer;
observer.observe(el);
}
function destroyResizeObserver(el) {
if (el.cwResizeObserver) {
el.cwResizeObserver.unobserve(el);
el.cwResizeObserver.disconnect();
delete el.cwResizeObserver;
}
}
export default {
bind(el, binding) {
createResizeObserver(el, binding);
},
update(el, binding) {
if (binding.oldValue !== binding.value) {
destroyResizeObserver(el);
createResizeObserver(el, binding);
}
},
unbind(el) {
destroyResizeObserver(el);
},
};
@@ -0,0 +1,78 @@
import resize from '../../directives/resize';
class ResizeObserverMock {
// eslint-disable-next-line class-methods-use-this
observe() {}
// eslint-disable-next-line class-methods-use-this
unobserve() {}
// eslint-disable-next-line class-methods-use-this
disconnect() {}
}
describe('resize directive', () => {
let el;
let binding;
let observer;
beforeEach(() => {
el = document.createElement('div');
binding = {
value: jest.fn(),
};
observer = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
window.ResizeObserver = ResizeObserverMock;
jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create ResizeObserver on bind', () => {
resize.bind(el, binding);
expect(ResizeObserver).toHaveBeenCalled();
expect(observer.observe).toHaveBeenCalledWith(el);
});
it('should call callback on observer callback', () => {
el = document.createElement('div');
binding = {
value: jest.fn(),
};
resize.bind(el, binding);
const entries = [{ contentRect: { width: 100, height: 100 } }];
const callback = binding.value;
callback(entries[0]);
expect(binding.value).toHaveBeenCalledWith(entries[0]);
});
it('should destroy and recreate observer on update', () => {
resize.bind(el, binding);
resize.update(el, { ...binding, oldValue: 'old' });
expect(observer.unobserve).toHaveBeenCalledWith(el);
expect(observer.disconnect).toHaveBeenCalled();
expect(ResizeObserver).toHaveBeenCalledTimes(2);
expect(observer.observe).toHaveBeenCalledTimes(2);
});
it('should destroy observer on unbind', () => {
resize.bind(el, binding);
resize.unbind(el);
expect(observer.unobserve).toHaveBeenCalledWith(el);
expect(observer.disconnect).toHaveBeenCalled();
});
});
@@ -64,7 +64,14 @@
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
"RT": "RT {status}",
"BREACH": "breach",
"DUE": "due"
}
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending",
@@ -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"
@@ -4,22 +4,9 @@
"ADD_ACTION": "Add SLA",
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"HEADER_BTN_TXT": "Add SLA",
"LOADING": "Fetching SLAs",
"SEARCH_404": "There are no items matching this query",
"SIDEBAR_TXT": "<p><b>SLA</b> <p>Think of Service Level Agreements (SLAs) like friendly promises between a service provider and a customer.</p> <p> These promises set clear expectations for things like how quickly the team will respond to issues, making sure you always get a reliable and top-notch experience!</p>",
"LIST": {
"404": "There are no SLAs available in this account.",
"TITLE": "Manage SLA",
"DESC": "SLAs: Friendly promises for great service!",
"TABLE_HEADER": [
"Name",
"Description",
"FRT",
"NRT",
"RT",
"Business Hours"
],
"BUSINESS_HOURS_ON": "Business hours on",
"BUSINESS_HOURS_OFF": "Business hours off",
"RESPONSE_TYPES": {
@@ -1,6 +1,23 @@
<script setup>
defineProps({
isLoading: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" />
<slot name="body" />
<div>
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<slot v-else name="body" />
</div>
</div>
</template>
@@ -12,7 +12,7 @@ defineProps({
</script>
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm sm:divide-x sm:divide-slate-75 sm:dark:divide-slate-700/50 group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
>
<!-- left side section -->
<slot name="leftSection">
@@ -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,99 +1,58 @@
<template>
<div class="flex-1 overflow-auto p-4">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('SLA.HEADER_BTN_TXT') }}
</woot-button>
<div class="flex flex-row gap-4">
<div class="w-full xl:w-3/5">
<p
v-if="!uiFlags.isFetching && !records.length"
class="flex h-full items-center flex-col justify-center"
>
{{ $t('SLA.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('SLA.LOADING')"
<settings-layout
:is-loading="uiFlags.isFetching"
:loading-message="$t('SLA.LOADING')"
>
<template #header>
<SLA-header @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="flex flex-col w-full h-full gap-3">
<SLA-list-item
v-for="sla in records"
:key="sla.title"
:sla-name="sla.name"
:description="sla.description"
:first-response="displayTime(sla.first_response_time_threshold)"
:next-response="displayTime(sla.next_response_time_threshold)"
:resolution-time="displayTime(sla.resolution_time_threshold)"
:has-business-hours="sla.only_during_business_hours"
:is-loading="loading[sla.id]"
@click="openDeletePopup(sla)"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th v-for="thHeader in $t('SLA.LIST.TABLE_HEADER')" :key="thHeader">
{{ thHeader }}
</th>
</thead>
<tbody>
<tr v-for="sla in records" :key="sla.title">
<td>
<span
class="inline-block overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ sla.name }}
</span>
</td>
<td>{{ sla.description }}</td>
<td>
<span class="flex items-center">
{{ displayTime(sla.first_response_time_threshold) }}
</span>
</td>
<td>
<span class="flex items-center">
{{ displayTime(sla.next_response_time_threshold) }}
</span>
</td>
<td>
<span class="flex items-center">
{{ displayTime(sla.resolution_time_threshold) }}
</span>
</td>
<td>
<span class="flex items-center">
{{ sla.only_during_business_hours }}
</span>
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="$t('SLA.FORM.DELETE')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[sla.id]"
@click="openDeletePopup(sla)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="w-1/3 hidden xl:block">
<span v-dompurify-html="$t('SLA.SIDEBAR_TXT')" />
</div>
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<add-SLA @close="hideAddPopup" />
</woot-modal>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<add-SLA @close="hideAddPopup" />
</woot-modal>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('SLA.DELETE.CONFIRM.TITLE')"
:message="$t('SLA.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('SLA.DELETE.CONFIRM.TITLE')"
:message="$t('SLA.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</template>
</settings-layout>
</template>
<script>
import SettingsLayout from '../SettingsLayout.vue';
import SLAHeader from './components/SLAHeader.vue';
import SLAListItem from './components/SLAListItem.vue';
import SLAListItemLoading from './components/SLAListItemLoading.vue';
import { mapGetters } from 'vuex';
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
@@ -103,6 +62,10 @@ import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
AddSLA,
SLAHeader,
SLAListItem,
SLAListItemLoading,
SettingsLayout,
},
mixins: [alertMixin],
data() {
@@ -35,7 +35,11 @@ defineProps({
});
</script>
<template>
<base-settings-list-item :title="slaName" :description="description">
<base-settings-list-item
class="sm:divide-x sm:divide-slate-75 sm:dark:divide-slate-700/50"
:title="slaName"
:description="description"
>
<template #label>
<SLA-business-hours-label :has-business-hours="hasBusinessHours" />
</template>
@@ -0,0 +1,30 @@
<script setup>
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
</script>
<template>
<base-settings-list-item class="opacity-50">
<template #title>
<div class="w-24 h-[26px] rounded-md bg-slate-50 animate-pulse" />
</template>
<template #description>
<div class="w-64 h-4 mb-0.5 rounded-md bg-slate-50 animate-pulse" />
<div class="w-48 h-4 rounded-md bg-slate-50 animate-pulse" />
</template>
<template #label>
<div class="w-32 h-[26px] bg-slate-50 animate-pulse rounded-md" />
</template>
<template #rightSection>
<div
class="flex items-center sm:rtl:!border-l-0 sm:rtl:!border-r sm:rtl:border-solid sm:rtl:border-slate-75 sm:rtl:dark:border-slate-700/50 gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between"
>
<div
v-for="ii in 3"
:key="ii"
class="flex justify-end w-1/3 h-full px-4"
>
<div class="w-32 h-full rounded-md bg-slate-50 animate-pulse" />
</div>
</div>
</template>
</base-settings-list-item>
</template>
@@ -1,18 +1,14 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/sla'),
component: SettingsContent,
props: {
headerTitle: 'SLA.HEADER',
icon: 'document-list-clock',
showNewButton: true,
},
component: SettingsWrapper,
props: {},
children: [
{
path: '',
+2
View File
@@ -30,6 +30,7 @@ import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import AnalyticsPlugin from '../dashboard/helper/AnalyticsHelper/plugin';
import resizeDirective from '../dashboard/helper/directives/resize.js';
Vue.config.env = process.env;
@@ -78,6 +79,7 @@ Vue.component('woot-switch', WootSwitch);
Vue.component('woot-wizard', WootWizard);
Vue.component('fluent-icon', FluentIcon);
Vue.directive('resize', resizeDirective);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
@@ -69,6 +69,18 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer
send_mail_with_liquid(to: email_to, subject: subject) and return
end
def automation_rule_disabled(rule)
return unless smtp_config_set_or_development?
@action_url ||= "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/automation/list"
subject = 'Automation rule disabled due to validation errors.'.freeze
@meta = {}
@meta['rule_name'] = rule.name
send_mail_with_liquid(to: admin_emails, subject: subject) and return
end
private
def admin_emails
@@ -61,7 +61,10 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
user: @agent,
conversation: @conversation,
inbox: @conversation.inbox,
message: @message
message: @message,
sla_policy: @sla_policy
})
end
end
AgentNotifications::ConversationNotificationsMailer.include_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.include_mod_with('Enterprise::ApplicationRecord')
+3
View File
@@ -19,6 +19,7 @@
#
class AutomationRule < ApplicationRecord
include Rails.application.routes.url_helpers
include Reauthorizable
belongs_to :account
has_many_attached :files
@@ -28,6 +29,8 @@ class AutomationRule < ApplicationRecord
validate :query_operator_presence
validates :account_id, presence: true
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
scope :active, -> { where(active: true) }
def conditions_attributes
+3
View File
@@ -50,6 +50,9 @@ module Reauthorizable
mailer.whatsapp_disconnect(inbox).deliver_later
when 'Channel::Email'
mailer.email_disconnect(inbox).deliver_later
when 'AutomationRule'
update!(active: false)
mailer.automation_rule_disabled(self).deliver_later
end
end
+2 -2
View File
@@ -6,7 +6,7 @@
# additional_attributes :jsonb
# agent_last_seen_at :datetime
# assignee_last_seen_at :datetime
# cached_label_list :string
# cached_label_list :text
# contact_last_seen_at :datetime
# custom_attributes :jsonb
# first_reply_created_at :datetime
@@ -312,5 +312,5 @@ class Conversation < ApplicationRecord
end
end
Conversation.include_mod_with('EnterpriseConversationConcern')
Conversation.include_mod_with('Concerns::Conversation')
Conversation.include_mod_with('SentimentAnalysisHelper')
+2 -2
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
''
@@ -45,3 +45,4 @@ class Conversations::EventDataPresenter < SimpleDelegator
}
end
end
Conversations::EventDataPresenter.prepend_mod_with('Conversations::EventDataPresenter')
@@ -45,8 +45,8 @@ class AutomationRules::ConditionsFilterService < FilterService
def rule_valid?
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
@rule.authorization_error! unless is_valid
is_valid
end
@@ -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,8 @@
<p>Hello there,</p>
<p>The automation rule <b>{{meta['rule_name']}}</b> has been disabled becuase it has invalid conditions.</p>
<p>This typically happens when you delete any custom attributes which are still being used in automation rules.</p>
<p>
Click <a href="{{action_url}}">here</a> to update the conditions.
</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>
+9
View File
@@ -106,6 +106,15 @@ en:
avg_resolution_time: Avg resolution time
conversation_traffic_csv:
timezone: Timezone
sla_csv:
conversation_id: Conversation ID
sla_policy_breached: SLA Policy
assignee: Assignee
team: Team
inbox: Inbox
labels: Labels
conversation_link: Link to the Conversation
breached_events: Breached Events
default_group_by: day
csat:
headers:
+6
View File
@@ -144,6 +144,12 @@ Rails.application.routes.draw do
get :download
end
end
resources :applied_slas, only: [:index] do
collection do
get :metrics
get :download
end
end
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -0,0 +1,32 @@
class ConvertCachedLabelListToText < ActiveRecord::Migration[7.0]
def up
change_column :conversations, :cached_label_list, :text
end
def down
# This might cause data loss if the text is longer than 255 characters
# lets start by truncating the data to 255 characters
Conversation.where('LENGTH(cached_label_list) > 255').find_in_batches do |conversation_batch|
Conversation.transaction do
conversation_batch.each do |conversation|
conversation.update!(cached_label_list: truncate_list(conversation.cached_label_list))
end
end
end
change_column :conversations, :cached_label_list, :string
end
private
# Truncate the list to 255 characters or less
# by removing the last element until the length is less than 255
def truncate_list(label_list)
labels = label_list.split(',')
# we add the `labels.length - 1` to account for the commas
labels.pop while (labels.join(',').length + labels.length - 1) > 255
labels.join(',')
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do
ActiveRecord::Schema[7.0].define(version: 2024_03_22_071629) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -472,7 +472,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do
t.integer "priority"
t.bigint "sla_policy_id"
t.datetime "waiting_since"
t.string "cached_label_list"
t.text "cached_label_list"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -0,0 +1,72 @@
class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAccountsController
include Sift
include DateRangeHelper
RESULTS_PER_PAGE = 25
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; end
def metrics
@total_applied_slas = total_applied_slas
@number_of_sla_breaches = number_of_sla_breaches
@hit_rate = hit_rate
end
def download
@breached_slas = breached_slas
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv'
render layout: false, formats: [:csv]
end
private
def breached_slas
@applied_slas.includes(:sla_policy).joins(:conversation)
.where.not(conversations: { status: :resolved })
.where(applied_slas: { sla_status: :missed })
end
def total_applied_slas
@total_applied_slas ||= @applied_slas.count
end
def number_of_sla_breaches
@number_of_sla_breaches ||= @applied_slas.missed.count
end
def hit_rate
number_of_sla_breaches.zero? ? '100%' : "#{hit_rate_percentage}%"
end
def hit_rate_percentage
((total_applied_slas - number_of_sla_breaches) / total_applied_slas.to_f * 100).round(2)
end
def set_applied_slas
initial_query = Current.account.applied_slas.includes(:conversation)
@applied_slas = initial_query
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_sla_policy_id(params[:sla_policy_id])
.filter_by_label_list(params[:label_list])
.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
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,7 +2,7 @@ class Sla::ProcessAccountAppliedSlasJob < ApplicationJob
queue_as :medium
def perform(account)
account.applied_slas.where(sla_status: 'active').each do |applied_sla|
account.applied_slas.where(sla_status: %w[active active_with_misses]).each do |applied_sla|
Sla::ProcessAppliedSlaJob.perform_later(applied_sla)
end
end
@@ -0,0 +1,32 @@
module Enterprise::AgentNotifications::ConversationNotificationsMailer
def sla_missed_first_response(conversation, agent, sla_policy)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@sla_policy = sla_policy
subject = "Conversation [ID - #{@conversation.display_id}] missed SLA for first response"
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def sla_missed_next_response(conversation, agent, sla_policy)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@sla_policy = sla_policy
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for next response") and return
end
def sla_missed_resolution(conversation, agent, sla_policy)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@sla_policy = sla_policy
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for resolution time") and return
end
end
+29 -1
View File
@@ -27,7 +27,35 @@ class AppliedSla < ApplicationRecord
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
before_validation :ensure_account_id
enum sla_status: { active: 0, hit: 1, missed: 2 }
enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 }
scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_inbox_id, ->(inbox_id) { where(inbox_id: inbox_id) if inbox_id.present? }
scope :filter_by_team_id, ->(team_id) { where(team_id: team_id) if team_id.present? }
scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? }
scope :filter_by_label_list, ->(label_list) { joins(:conversation).where(conversations: { cached_label_list: label_list }) if label_list.present? }
scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id|
if assigned_agent_id.present?
joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id })
end
}
scope :missed, -> { where(sla_status: :missed) }
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
@@ -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
+35
View File
@@ -31,6 +31,17 @@ 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
@@ -49,4 +60,28 @@ class SlaEvent < ApplicationRecord
def ensure_sla_policy_id
self.sla_policy_id ||= applied_sla&.sla_policy_id
end
def create_notifications
notify_users = conversation.conversation_participants.map(&:user)
# Add all admins from the account to notify list
notify_users += account.administrators
# Ensure conversation assignee is notified
notify_users += [conversation.assignee] if conversation.assignee.present?
notification_type = {
'frt' => 'sla_missed_first_response',
'nrt' => 'sla_missed_next_response',
'rt' => 'sla_missed_resolution'
}[event_type]
notify_users.uniq.each do |user|
NotificationBuilder.new(
notification_type: notification_type,
user: user,
account: account,
primary_actor: conversation,
secondary_actor: sla_policy
).perform
end
end
end
@@ -0,0 +1,12 @@
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)
)
else
super
end
end
end
@@ -7,8 +7,8 @@ class Sla::EvaluateAppliedSlaService
# We will calculate again in the next iteration
return unless applied_sla.conversation.resolved?
# No SLA missed, so marking as hit as conversation is resolved
handle_hit_sla(applied_sla) if applied_sla.active?
# after conversation is resolved, we will check if the SLA was hit or missed
handle_hit_sla(applied_sla)
end
private
@@ -49,6 +49,14 @@ class Sla::EvaluateAppliedSlaService
handle_missed_sla(applied_sla, 'nrt')
end
def get_last_message_id(conversation)
conversation.messages.where(message_type: :incoming).last&.id
end
def already_missed?(applied_sla, type, meta = {})
SlaEvent.exists?(applied_sla: applied_sla, event_type: type, meta: meta)
end
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
return if conversation.resolved?
@@ -58,48 +66,41 @@ class Sla::EvaluateAppliedSlaService
handle_missed_sla(applied_sla, 'rt')
end
def handle_missed_sla(applied_sla, type)
return unless applied_sla.active?
def handle_missed_sla(applied_sla, type, meta = {})
meta = { message_id: get_last_message_id(applied_sla.conversation) } if type == 'nrt'
return if already_missed?(applied_sla, type, meta)
applied_sla.update!(sla_status: 'missed')
generate_notifications_for_sla(applied_sla, type)
Rails.logger.warn "SLA missed for conversation #{applied_sla.conversation.id} " \
create_sla_event(applied_sla, type, meta)
Rails.logger.warn "SLA #{type} missed for conversation #{applied_sla.conversation.id} " \
"in account #{applied_sla.account_id} " \
"for sla_policy #{applied_sla.sla_policy.id}"
applied_sla.update!(sla_status: 'active_with_misses') if applied_sla.sla_status != 'active_with_misses'
end
def handle_hit_sla(applied_sla)
return unless applied_sla.active?
applied_sla.update!(sla_status: 'hit')
Rails.logger.info "SLA hit for conversation #{applied_sla.conversation.id} " \
"in account #{applied_sla.account_id} " \
"for sla_policy #{applied_sla.sla_policy.id}"
end
def generate_notifications_for_sla(applied_sla, type)
notify_users = applied_sla.conversation.conversation_participants.map(&:user)
# add all admins from the account to notify list
notify_users += applied_sla.account.administrators
# ensure conversation assignee is notified
notify_users += [applied_sla.conversation.assignee] if applied_sla.conversation.assignee.present?
notification_type = if type == 'frt'
'sla_missed_first_response'
elsif type == 'nrt'
'sla_missed_next_response'
else
'sla_missed_resolution'
end
notify_users.uniq.each do |user|
NotificationBuilder.new(
notification_type: notification_type,
user: user,
account: applied_sla.account,
primary_actor: applied_sla.conversation,
secondary_actor: applied_sla.sla_policy
).perform
if applied_sla.active?
applied_sla.update!(sla_status: 'hit')
Rails.logger.info "SLA hit for conversation #{applied_sla.conversation.id} " \
"in account #{applied_sla.account_id} " \
"for sla_policy #{applied_sla.sla_policy.id}"
else
applied_sla.update!(sla_status: 'missed')
Rails.logger.info "SLA missed for conversation #{applied_sla.conversation.id} " \
"in account #{applied_sla.account_id} " \
"for sla_policy #{applied_sla.sla_policy.id}"
end
end
def create_sla_event(applied_sla, event_type, meta = {})
SlaEvent.create!(
applied_sla: applied_sla,
conversation: applied_sla.conversation,
event_type: event_type,
meta: meta,
account: applied_sla.account,
inbox: applied_sla.conversation.inbox,
sla_policy: applied_sla.sla_policy
)
end
end
@@ -0,0 +1,26 @@
<% headers = [
I18n.t('reports.sla_csv.conversation_id'),
I18n.t('reports.sla_csv.sla_policy_breached'),
I18n.t('reports.sla_csv.assignee'),
I18n.t('reports.sla_csv.team'),
I18n.t('reports.sla_csv.inbox'),
I18n.t('reports.sla_csv.labels'),
I18n.t('reports.sla_csv.conversation_link'),
I18n.t('reports.sla_csv.breached_events')
] %>
<%= CSV.generate_line headers %>
<% @breached_slas.each do |sla| %>
<% breached_events = sla.sla_events.map(&:event_type).join(', ') %>
<% conversation = sla.conversation %>
<%= CSV.generate_line([
conversation.display_id,
sla.sla_policy.name,
conversation.assignee&.name,
conversation.team&.name,
conversation.inbox&.name,
conversation.cached_label_list,
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id),
breached_events
]) %>
<% end %>
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,3 @@
json.total_applied_slas @total_applied_slas
json.number_of_sla_breaches @number_of_sla_breaches
json.hit_rate @hit_rate
@@ -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,5 @@
json.id sla_event.id
json.event_type sla_event.event_type
json.meta sla_event.meta
json.updated_at sla_event.updated_at.to_i
json.created_at sla_event.created_at.to_i
@@ -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
+1 -1
View File
@@ -63,7 +63,7 @@
"libphonenumber-js": "^1.10.24",
"logrocket": "^3.0.1",
"logrocket-vuex": "^0.0.3",
"markdown-it": "^13.0.1",
"markdown-it": "^13.0.2",
"markdown-it-link-attributes": "^4.0.1",
"md5": "^2.3.0",
"ninja-keys": "^1.2.2",
-16
View File
@@ -67,21 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
expect(user.encrypted_password).not_to be_empty
end
end
context 'with confirmation required' do
let(:unconfirmed_user) { create(:user, email: email) }
before do
unconfirmed_user.confirmed_at = nil
unconfirmed_user.save(validate: false)
allow(unconfirmed_user).to receive(:confirmed?).and_return(false)
end
it 'sends confirmation instructions' do
user = agent_builder.perform
expect(user).to receive(:send_confirmation_instructions)
agent_builder.send(:send_confirmation_if_required)
end
end
end
end
@@ -0,0 +1,218 @@
require 'rails_helper'
RSpec.describe 'Applied SLAs API', type: :request do
let(:account) { create(:account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent1) { create(:user, account: account, role: :agent) }
let(:agent2) { create(:user, account: account, role: :agent) }
let(:conversation1) { create(:conversation, account: account, assignee: agent1) }
let(:conversation2) { create(:conversation, account: account, assignee: agent2) }
let(:conversation3) { create(:conversation, account: account, assignee: agent2) }
let(:sla_policy1) { create(:sla_policy, account: account) }
let(:sla_policy2) { create(:sla_policy, account: account) }
before do
AppliedSla.destroy_all
end
describe 'GET /api/v1/accounts/{account.id}/applied_slas/metrics' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/applied_slas/metrics"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns the sla metrics' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed')
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 1)
expect(body).to include('number_of_sla_breaches' => 1)
expect(body).to include('hit_rate' => '0.0%')
end
it 'filters sla metrics based on a date range' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 1)
expect(body).to include('number_of_sla_breaches' => 0)
expect(body).to include('hit_rate' => '100%')
end
it 'filters sla metrics based on a date range and agent ids' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
params: { agent_ids: [agent2.id] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 3)
expect(body).to include('number_of_sla_breaches' => 1)
expect(body).to include('hit_rate' => '66.67%')
end
it 'filters sla metrics based on sla policy ids' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2, sla_status: 'missed')
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
params: { sla_policy_id: sla_policy1.id },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 2)
expect(body).to include('number_of_sla_breaches' => 1)
expect(body).to include('hit_rate' => '50.0%')
end
it 'filters sla metrics based on labels' do
conversation2.update_labels('label1')
conversation3.update_labels('label1')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
params: { label_list: ['label1'] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body).to include('total_applied_slas' => 2)
expect(body).to include('number_of_sla_breaches' => 1)
expect(body).to include('hit_rate' => '50.0%')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/applied_slas/download' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/applied_slas/download"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns a CSV file with breached conversations' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed')
conversation1.update(status: 'open')
conversation2.update(status: 'resolved')
get "/api/v1/accounts/#{account.id}/applied_slas/download",
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('text/csv')
expect(response.headers['Content-Disposition']).to include('attachment; filename=breached_conversation.csv')
csv_data = CSV.parse(response.body)
csv_data.reject! { |row| row.all?(&:nil?) }
expect(csv_data.size).to eq(2)
expect(csv_data[1][0].to_i).to eq(conversation1.display_id)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/applied_slas' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/applied_slas"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns the applied slas' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2)
get "/api/v1/accounts/#{account.id}/applied_slas",
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body.size).to eq(2)
expect(body.first).to include('id')
expect(body.first).to include('sla_policy_id' => sla_policy1.id)
expect(body.first).to include('conversation_id' => conversation1.id)
end
it 'filters applied slas based on a date range' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/applied_slas",
params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body.size).to eq(1)
end
it 'filters applied slas based on a date range and agent ids' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/applied_slas",
params: { agent_ids: [agent2.id] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body.size).to eq(3)
end
it 'filters applied slas based on sla policy ids' do
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2)
create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2)
get "/api/v1/accounts/#{account.id}/applied_slas",
params: { sla_policy_id: sla_policy1.id },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body.size).to eq(2)
end
it 'filters applied slas based on labels' do
conversation2.update_labels('label1')
conversation3.update_labels('label1')
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago)
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/applied_slas",
params: { label_list: ['label1'] },
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body.size).to eq(2)
end
end
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
@@ -7,18 +7,20 @@ RSpec.describe Sla::ProcessAccountAppliedSlasJob do
let!(:applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'active') }
let!(:hit_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'hit') }
let!(:miss_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'missed') }
let!(:active_with_misses_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'active_with_misses') }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('medium')
end
it 'calls the ProcessAppliedSlaJob' do
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
described_class.perform_now(account)
end
it 'does not call the ProcessAppliedSlaJob for not active applied slas' do
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)
described_class.perform_now(account)
@@ -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)
+48
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)
@@ -25,4 +40,37 @@ RSpec.describe SlaEvent, type: :model do
expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id
end
end
describe 'create notifications' do
# create account, user and inbox
let!(:account) { create(:account) }
let!(:assignee) { create(:user, account: account) }
let!(:participant) { create(:user, account: account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: assignee, account: account) }
let(:sla_policy) { create(:sla_policy, account: conversation.account) }
let(:sla_event) { create(:sla_event, event_type: 'frt', conversation: conversation, sla_policy: sla_policy) }
before do
# to ensure notifications are not sent to other users
create(:user, account: account)
create(:inbox_member, inbox: inbox, user: participant)
create(:conversation_participant, conversation: conversation, user: participant)
end
it 'creates notifications for conversation participants, admins, and assignee' do
sla_event
expect(Notification.count).to eq(3)
# check if notification type is sla_missed_first_response
expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(3)
# Check if notification is created for the assignee
expect(Notification.where(user_id: assignee.id).count).to eq(1)
# Check if notification is created for the account admin
expect(Notification.where(user_id: admin.id).count).to eq(1)
# Check if notification is created for participant
expect(Notification.where(user_id: participant.id).count).to eq(1)
end
end
end
@@ -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
@@ -3,8 +3,6 @@ require 'rails_helper'
RSpec.describe Sla::EvaluateAppliedSlaService do
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:user_2) { create(:user, account: account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:sla_policy) do
create(:sla_policy,
@@ -28,19 +26,17 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
expect(Rails.logger).to have_received(:warn).with("SLA frt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('missed')
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
expect(Notification.count).to eq(2)
# check if notification type is sla_missed_first_response
expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(2)
# Check if notification is created for the assignee
expect(Notification.where(user_id: user_1.id).count).to eq(1)
# Check if notification is created for the account admin
expect(Notification.where(user_id: admin.id).count).to eq(1)
# Check if no notification is created for other user
expect(Notification.where(user_id: user_2.id).count).to eq(0)
it 'creates SlaEvent only for frt miss' do
described_class.new(applied_sla: applied_sla).perform
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(1)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(0)
end
end
@@ -53,19 +49,17 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('missed')
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
expect(Notification.count).to eq(2)
# check if notification type is sla_missed_first_response
expect(Notification.where(notification_type: 'sla_missed_next_response').count).to eq(2)
# Check if notification is created for the assignee
expect(Notification.where(user_id: user_1.id).count).to eq(1)
# Check if notification is created for the account admin
expect(Notification.where(user_id: admin.id).count).to eq(1)
# Check if no notification is created for other user
expect(Notification.where(user_id: user_2.id).count).to eq(0)
it 'creates SlaEvent only for nrt miss' do
described_class.new(applied_sla: applied_sla).perform
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(1)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(0)
end
end
@@ -75,18 +69,18 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('missed')
expect(Notification.count).to eq(2)
expect(Notification.where(notification_type: 'sla_missed_resolution').count).to eq(2)
# Check if notification is created for the assignee
expect(Notification.where(user_id: user_1.id).count).to eq(1)
# Check if notification is created for the account admin
expect(Notification.where(user_id: admin.id).count).to eq(1)
# Check if no notification is created for other user
expect(Notification.where(user_id: user_2.id).count).to eq(0)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
it 'creates SlaEvent only for rt miss' do
described_class.new(applied_sla: applied_sla).perform
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
end
end
@@ -110,13 +104,14 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
conversation.update(first_reply_created_at: 5.hours.ago, waiting_since: 5.hours.ago)
end
it 'updates the SLA status to missed and logs a warning' do
it 'updates the SLA status to missed and logs multiple warnings' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time
expect(applied_sla.reload.sla_status).to eq('missed')
expect(Notification.count).to eq(2)
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
end
@@ -140,6 +135,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('hit')
expect(SlaEvent.count).to eq(0)
expect(Notification.count).to eq(0)
end
end
@@ -162,6 +158,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('hit')
expect(SlaEvent.count).to eq(0)
end
end
@@ -177,7 +174,52 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('hit')
expect(SlaEvent.count).to eq(0)
end
end
end
describe 'SLA evaluation with frt hit, multiple nrt misses and rt miss' do
before do
# Setup SLA Policy thresholds
sla_policy.update(
first_response_time_threshold: 2.hours, # Hit frt
next_response_time_threshold: 1.hour, # Miss nrt multiple times
resolution_time_threshold: 4.hours # Miss rt
)
# Simulate conversation timeline
# Hit frt
# incoming message from customer
create(:message, conversation: conversation, created_at: 6.hours.ago, message_type: :incoming)
# outgoing message from agent within frt
create(:message, conversation: conversation, created_at: 5.hours.ago, message_type: :outgoing)
# Miss nrt first time
create(:message, conversation: conversation, created_at: 4.hours.ago, message_type: :incoming)
described_class.new(applied_sla: applied_sla).perform
# Miss nrt second time
create(:message, conversation: conversation, created_at: 3.hours.ago, message_type: :incoming)
described_class.new(applied_sla: applied_sla).perform
# Conversation is resolved missing rt
conversation.update(status: 'resolved')
# this will not create a new notification for rt miss as conversation is resolved
# but we would have already created an rt miss notification during previous evaluation
described_class.new(applied_sla: applied_sla).perform
end
it 'updates the SLA status to missed' do
# the status would be missed as the conversation is resolved
expect(applied_sla.reload.sla_status).to eq('missed')
end
it 'creates necessary sla events' do
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(2)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
end
end
end
+36
View File
@@ -1,6 +1,11 @@
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe AutomationRule do
describe 'concerns' do
it_behaves_like 'reauthorizable'
end
describe 'associations' do
let(:account) { create(:account) }
let(:params) do
@@ -56,4 +61,35 @@ RSpec.describe AutomationRule do
expect(rule.errors.messages[:conditions]).to eq(['Automation conditions should have query operator.'])
end
end
describe 'reauthorizable' do
context 'when prompt_reauthorization!' do
it 'marks the rule inactive' do
rule = create(:automation_rule)
expect(rule.active).to be true
rule.prompt_reauthorization!
expect(rule.active).to be false
end
end
context 'when reauthorization_required?' do
it 'unsets the error count if conditions are updated' do
rule = create(:automation_rule)
rule.prompt_reauthorization!
expect(rule.reauthorization_required?).to be true
rule.update!(conditions: [{ attribute_key: 'browser_language', filter_operator: 'equal_to', values: ['en'], query_operator: 'AND' }])
expect(rule.reauthorization_required?).to be false
end
it 'will not unset the error count if conditions are not updated' do
rule = create(:automation_rule)
rule.prompt_reauthorization!
expect(rule.reauthorization_required?).to be true
rule.update!(name: 'Updated name')
expect(rule.reauthorization_required?).to be true
end
end
end
end
@@ -25,10 +25,19 @@ shared_examples_for 'reauthorizable' do
it 'prompt_reauthorization!' do
obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym)
mailer = double
mailer_method = double
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(mailer)
# allow mailer to receive any methods and return mailer
allow(mailer).to receive(:method_missing).and_return(mailer_method)
allow(mailer_method).to receive(:deliver_later)
expect(obj.reauthorization_required?).to be false
obj.prompt_reauthorization!
expect(obj.reauthorization_required?).to be true
expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account)
expect(mailer_method).to have_received(:deliver_later)
end
it 'reauthorized!' do
@@ -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
+31 -26
View File
@@ -8140,13 +8140,13 @@ bn.js@^5.2.1:
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70"
integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==
body-parser@1.20.1:
version "1.20.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==
body-parser@1.20.2:
version "1.20.2"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==
dependencies:
bytes "3.1.2"
content-type "~1.0.4"
content-type "~1.0.5"
debug "2.6.9"
depd "2.0.0"
destroy "1.2.0"
@@ -8154,7 +8154,7 @@ body-parser@1.20.1:
iconv-lite "0.4.24"
on-finished "2.4.1"
qs "6.11.0"
raw-body "2.5.1"
raw-body "2.5.2"
type-is "~1.6.18"
unpipe "1.0.0"
@@ -9129,6 +9129,11 @@ content-type@~1.0.4:
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
content-type@~1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
@@ -9146,10 +9151,10 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
copy-concurrently@^1.0.0:
version "1.0.5"
@@ -11054,16 +11059,16 @@ expect@^29.0.0, expect@^29.7.0:
jest-util "^29.7.0"
express@^4.17.1:
version "4.18.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59"
integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==
version "4.19.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465"
integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"
body-parser "1.20.1"
body-parser "1.20.2"
content-disposition "0.5.4"
content-type "~1.0.4"
cookie "0.5.0"
cookie "0.6.0"
cookie-signature "1.0.6"
debug "2.6.9"
depd "2.0.0"
@@ -11407,9 +11412,9 @@ flush-write-stream@^1.0.0:
readable-stream "^2.3.6"
follow-redirects@^1.0.0, follow-redirects@^1.15.0:
version "1.15.3"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a"
integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==
version "1.15.6"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
for-each@^0.3.3:
version "0.3.3"
@@ -14551,10 +14556,10 @@ markdown-it@^10.0.0:
mdurl "^1.0.1"
uc.micro "^1.0.5"
markdown-it@^13.0.1:
version "13.0.1"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430"
integrity sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==
markdown-it@^13.0.2:
version "13.0.2"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.2.tgz#1bc22e23379a6952e5d56217fbed881e0c94d536"
integrity sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==
dependencies:
argparse "^2.0.1"
entities "~3.0.1"
@@ -17578,10 +17583,10 @@ range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
raw-body@2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"