Merge branch 'develop' into feat/scroll-to-notification

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