Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5343b3f8ae | ||
|
|
11ae25535a | ||
|
|
d8708e5ee5 | ||
|
|
3efdd446a9 | ||
|
|
85d970754b | ||
|
|
7cc6adbd9e | ||
|
|
6cad9c683a |
@@ -65,10 +65,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def contactable_inboxes
|
||||
|
||||
@@ -36,18 +36,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@conversation.update!(permitted_update_params)
|
||||
end
|
||||
|
||||
def filter
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
@conversations = result[:conversations]
|
||||
@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
|
||||
@@ -118,11 +110,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
private
|
||||
|
||||
def permitted_update_params
|
||||
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
|
||||
params.permit(:priority)
|
||||
end
|
||||
|
||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||
@@ -189,5 +176,3 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversation.assignee_id? && Current.user == @conversation.assignee
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessTokenAuthHelper
|
||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
|
||||
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create],
|
||||
'api/v1/accounts/conversations/messages' => ['create'],
|
||||
'api/v1/accounts/conversations/assignments' => ['create']
|
||||
}.freeze
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module DomainHelper
|
||||
def self.chatwoot_domain?(domain = request.host)
|
||||
[URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,6 @@ 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, &)
|
||||
@@ -17,20 +16,6 @@ module SwitchLocale
|
||||
set_locale(locale, &)
|
||||
end
|
||||
|
||||
# If the request is coming from a custom domain, it should be for a helpcenter portal
|
||||
# We will use the portal locale in such cases
|
||||
def locale_from_custom_domain(&)
|
||||
return if params[:locale]
|
||||
|
||||
domain = request.host
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
@portal = Portal.find_by(custom_domain: domain)
|
||||
return unless @portal
|
||||
|
||||
@portal.default_locale
|
||||
end
|
||||
|
||||
def set_locale(locale, &)
|
||||
# if locale is empty, use default_locale
|
||||
locale ||= I18n.default_locale
|
||||
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
article.portal.default_locale
|
||||
'en'
|
||||
end
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
|
||||
@@ -8,7 +8,8 @@ class PublicController < ActionController::Base
|
||||
|
||||
def ensure_custom_domain_request
|
||||
domain = request.host
|
||||
return if DomainHelper.chatwoot_domain?(domain)
|
||||
|
||||
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
|
||||
|
||||
@portal = ::Portal.find_by(custom_domain: domain)
|
||||
return if @portal.present?
|
||||
|
||||
@@ -2,11 +2,4 @@ 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
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
module FilterHelper
|
||||
def build_condition_query(model_filters, query_hash, current_index)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
# Throw InvalidOperator Error if the attribute is a standard attribute
|
||||
# and the operator is not allowed in the config
|
||||
if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator])
|
||||
raise CustomExceptions::CustomFilter::InvalidOperator.new(
|
||||
attribute_name: query_hash['attribute_key'],
|
||||
allowed_keys: current_filter['filter_operators']
|
||||
)
|
||||
end
|
||||
|
||||
# Every other filter expects a value to be present
|
||||
if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank?
|
||||
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key'])
|
||||
end
|
||||
|
||||
condition_query = build_condition_query_string(current_filter, query_hash, current_index)
|
||||
# The query becomes empty only when it doesn't match to any supported
|
||||
# standard attribute or custom attribute defined in the account.
|
||||
if condition_query.empty?
|
||||
raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'],
|
||||
allowed_keys: model_filters.keys)
|
||||
end
|
||||
|
||||
condition_query
|
||||
end
|
||||
|
||||
def build_condition_query_string(current_filter, query_hash, current_index)
|
||||
filter_operator_value = filter_operation(query_hash, current_index)
|
||||
|
||||
return handle_nil_filter(query_hash, current_index) if current_filter.nil?
|
||||
|
||||
case current_filter['attribute_type']
|
||||
when 'additional_attributes'
|
||||
handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type'])
|
||||
else
|
||||
handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_nil_filter(query_hash, current_index)
|
||||
attribute_type = "#{filter_config[:entity].downcase}_attribute"
|
||||
custom_attribute_query(query_hash, attribute_type, current_index)
|
||||
end
|
||||
|
||||
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
|
||||
if data_type == 'text_case_insensitive'
|
||||
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
else
|
||||
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]} "
|
||||
end
|
||||
end
|
||||
|
||||
def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value)
|
||||
case current_filter['data_type']
|
||||
when 'date'
|
||||
date_filter(current_filter, query_hash, filter_operator_value)
|
||||
when 'labels'
|
||||
tag_filter_query(query_hash, current_index)
|
||||
when 'text_case_insensitive'
|
||||
text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
else
|
||||
default_filter(query_hash, filter_operator_value)
|
||||
end
|
||||
end
|
||||
|
||||
def date_filter(current_filter, query_hash, filter_operator_value)
|
||||
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
|
||||
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||
"LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \
|
||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
end
|
||||
@@ -84,24 +84,6 @@ class ReportsAPI extends ApiClient {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getBotMetrics({ from, to } = {}) {
|
||||
return axios.get(`${this.url}/bot_metrics`, {
|
||||
params: { since: from, until: to },
|
||||
});
|
||||
}
|
||||
|
||||
getBotSummary({ from, to, groupBy, businessHours } = {}) {
|
||||
return axios.get(`${this.url}/bot_summary`, {
|
||||
params: {
|
||||
since: from,
|
||||
until: to,
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportsAPI();
|
||||
|
||||
@@ -111,40 +111,6 @@ describe('#Reports API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('#getBotMetrics', () => {
|
||||
reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/bot_metrics',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getBotSummary', () => {
|
||||
reportsAPI.getBotSummary({
|
||||
from: 1621103400,
|
||||
to: 1621621800,
|
||||
groupBy: 'date',
|
||||
businessHours: true,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/bot_summary',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
group_by: 'date',
|
||||
business_hours: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getConversationMetric', () => {
|
||||
reportsAPI.getConversationMetric('account');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
@import 'shared/assets/fonts/inter';
|
||||
// Inter,
|
||||
// @import 'shared/assets/fonts/inter';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'PlusJakarta',
|
||||
Inter,
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const reports = accountId => ({
|
||||
@@ -7,7 +6,6 @@ const reports = accountId => ({
|
||||
'account_overview_reports',
|
||||
'conversation_reports',
|
||||
'csat_reports',
|
||||
'bot_reports',
|
||||
'agent_reports',
|
||||
'label_reports',
|
||||
'inbox_reports',
|
||||
@@ -35,14 +33,6 @@ const reports = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/reports/csat`),
|
||||
toStateName: 'csat_reports',
|
||||
},
|
||||
{
|
||||
icon: 'bot',
|
||||
label: 'REPORTS_BOT',
|
||||
hasSubMenu: false,
|
||||
featureFlag: FEATURE_FLAGS.RESPONSE_BOT,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/bot`),
|
||||
toStateName: 'bot_reports',
|
||||
},
|
||||
{
|
||||
icon: 'people',
|
||||
label: 'REPORTS_AGENT',
|
||||
|
||||
@@ -1,45 +1,17 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mapUrl = computed(
|
||||
() => `https://maps.google.com/?q=${props.latitude},${props.longitude}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row items-center justify-start gap-1 w-full py-1 px-0 cursor-pointer overflow-hidden"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="location"
|
||||
class="text-slate-600 dark:text-slate-200 leading-none my-0 flex items-center flex-shrink-0"
|
||||
size="32"
|
||||
/>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<div class="location message-text__wrap">
|
||||
<div class="icon-wrap">
|
||||
<fluent-icon icon="location" class="file--icon" size="32" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h5
|
||||
class="text-sm text-slate-800 dark:text-slate-100 truncate m-0 w-full"
|
||||
:title="name"
|
||||
class="text-sm text-slate-800 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ name }}
|
||||
</h5>
|
||||
<div class="flex items-center">
|
||||
<div class="link-wrap">
|
||||
<a
|
||||
class="text-woot-600 dark:text-woot-600 text-xs underline"
|
||||
class="download clear link button small"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:href="mapUrl"
|
||||
@@ -50,3 +22,49 @@ const mapUrl = computed(
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
mapUrl() {
|
||||
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.location {
|
||||
@apply flex flex-row py-1 px-0 cursor-pointer;
|
||||
|
||||
.icon-wrap {
|
||||
@apply text-slate-600 dark:text-slate-200 leading-none my-0 mx-1;
|
||||
}
|
||||
|
||||
.text-block-title {
|
||||
@apply m-0 text-slate-800 dark:text-slate-100 break-words;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@apply flex flex-col items-center pr-4;
|
||||
}
|
||||
|
||||
.link-wrap {
|
||||
@apply flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,15 +19,4 @@ export const FEATURE_FLAGS = {
|
||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
const { helpUrls } = window.chatwootConfig;
|
||||
return helpUrls[featureName];
|
||||
}
|
||||
@@ -44,8 +44,7 @@
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created At",
|
||||
"LAST_ACTIVITY": "Last Activity",
|
||||
"REFERER_LINK": "Referrer link",
|
||||
"BLOCKED": "Blocked"
|
||||
"REFERER_LINK": "Referrer link"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard Filters",
|
||||
|
||||
@@ -35,14 +35,6 @@
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_RESOLUTION_COUNT": {
|
||||
"NAME": "Resolution Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"BOT_HANDOFF_COUNT": {
|
||||
"NAME": "Handoff Count",
|
||||
"DESC": "( Total )"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"NAME": "Customer waiting time",
|
||||
"TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} replies)"
|
||||
@@ -94,49 +86,20 @@
|
||||
"MONTH": "Month",
|
||||
"YEAR": "Year"
|
||||
},
|
||||
"GROUP_BY_DAY_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
}
|
||||
],
|
||||
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
|
||||
"GROUP_BY_WEEK_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
}
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
{ "id": 2, "groupBy": "Week" }
|
||||
],
|
||||
"GROUP_BY_MONTH_OPTIONS": [
|
||||
{
|
||||
"id": 1,
|
||||
"groupBy": "Day"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
}
|
||||
{ "id": 1, "groupBy": "Day" },
|
||||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" }
|
||||
],
|
||||
"GROUP_BY_YEAR_OPTIONS": [
|
||||
{
|
||||
"id": 2,
|
||||
"groupBy": "Week"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"groupBy": "Month"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"groupBy": "Year"
|
||||
}
|
||||
{ "id": 2, "groupBy": "Week" },
|
||||
{ "id": 3, "groupBy": "Month" },
|
||||
{ "id": 4, "groupBy": "Year" }
|
||||
],
|
||||
"BUSINESS_HOURS": "Business Hours"
|
||||
},
|
||||
@@ -441,27 +404,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BOT_REPORTS": {
|
||||
"HEADER": "Bot Reports",
|
||||
"METRIC": {
|
||||
"TOTAL_CONVERSATIONS": {
|
||||
"LABEL": "No. of Conversations",
|
||||
"TOOLTIP": "Total number of conversations handled by the bot"
|
||||
},
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Total Responses",
|
||||
"TOOLTIP": "Total number of responses sent by the bot"
|
||||
},
|
||||
"RESOLUTION_RATE": {
|
||||
"LABEL": "Resolution Rate",
|
||||
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
|
||||
},
|
||||
"HANDOFF_RATE": {
|
||||
"LABEL": "Handoff Rate",
|
||||
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OVERVIEW_REPORTS": {
|
||||
"HEADER": "Overview",
|
||||
"LIVE": "Live",
|
||||
|
||||
@@ -234,7 +234,6 @@
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
"REPORTS_BOT": "Bot",
|
||||
"REPORTS_AGENT": "Agents",
|
||||
"REPORTS_LABEL": "Labels",
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"SLA": {
|
||||
"HEADER": "SLA",
|
||||
"ADD_ACTION": "Add SLA",
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"HEADER_BTN_TXT": "Add SLA",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"SEARCH_404": "There are no items matching this query",
|
||||
@@ -12,26 +9,7 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
@@ -77,17 +55,11 @@
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete SLA",
|
||||
"EDIT": {
|
||||
"TITLE": "Edit SLA",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "SLA deleted successfully",
|
||||
"SUCCESS_MESSAGE": "SLA updated successfully",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"MESSAGE": "Are you sure you want to delete ",
|
||||
"YES": "Yes, Delete ",
|
||||
"NO": "No, Keep "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,11 @@ import { mapGetters } from 'vuex';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
accountSummaryKey: {
|
||||
type: String,
|
||||
default: 'getAccountSummary',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountSummary: 'getAccountSummary',
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
accountSummary() {
|
||||
return this.$store.getters[this.accountSummaryKey];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
calculateTrend(key) {
|
||||
|
||||
@@ -11,42 +11,11 @@ describe('reportMixin', () => {
|
||||
beforeEach(() => {
|
||||
getters = {
|
||||
getAccountSummary: () => reportFixtures.summary,
|
||||
getBotSummary: () => reportFixtures.botSummary,
|
||||
getAccountReports: () => reportFixtures.report,
|
||||
};
|
||||
store = new Vuex.Store({ getters });
|
||||
});
|
||||
|
||||
it('display the metric for account', async () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
await wrapper.setProps({
|
||||
accountSummaryKey: 'getAccountSummary',
|
||||
});
|
||||
expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
|
||||
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
|
||||
'3 Min 18 Sec'
|
||||
);
|
||||
});
|
||||
|
||||
it('display the metric for bot', async () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [reportMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
await wrapper.setProps({
|
||||
accountSummaryKey: 'getBotSummary',
|
||||
});
|
||||
expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10');
|
||||
expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20');
|
||||
});
|
||||
|
||||
it('display the metric', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
||||
@@ -15,14 +15,6 @@ export default {
|
||||
},
|
||||
resolutions_count: 3,
|
||||
},
|
||||
botSummary: {
|
||||
bot_resolutions_count: 10,
|
||||
bot_handoffs_count: 20,
|
||||
previous: {
|
||||
bot_resolutions_count: 8,
|
||||
bot_handoffs_count: 5,
|
||||
},
|
||||
},
|
||||
report: {
|
||||
data: [
|
||||
{ value: '0.00', timestamp: 1647541800, count: 0 },
|
||||
|
||||
+1
-1
@@ -243,7 +243,7 @@ export default {
|
||||
attr.attribute_display_type === 'checkbox'
|
||||
);
|
||||
});
|
||||
if (isCustomAttributeCheckbox || type === 'blocked') {
|
||||
if (isCustomAttributeCheckbox) {
|
||||
return [
|
||||
{
|
||||
id: true,
|
||||
|
||||
@@ -76,14 +76,6 @@ 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 = [
|
||||
@@ -123,10 +115,6 @@ export const filterAttributeGroups = [
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
{
|
||||
key: 'blocked',
|
||||
i18nKey: 'BLOCKED',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full gap-10 font-inter">
|
||||
<slot name="header" />
|
||||
<slot name="body" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
linkText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const openInNewTab = url => {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener noreferrer');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-4">
|
||||
<!-- Header section with icon, title and action button -->
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<!-- Icon and title container -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
|
||||
>
|
||||
<fluent-icon
|
||||
size="14"
|
||||
:icon="iconName"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1
|
||||
class="text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Slot for additional actions on larger screens -->
|
||||
<div class="hidden gap-2 sm:flex">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description and optional link -->
|
||||
<div
|
||||
class="flex flex-col gap-2 text-slate-600 dark:text-slate-300 max-w-[721px] w-full"
|
||||
>
|
||||
<p
|
||||
class="mb-0 text-sm font-normal tracking-[0.5%] line-clamp-5 sm:line-clamp-none"
|
||||
>
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
<!-- Conditional link -->
|
||||
<a
|
||||
v-if="href && linkText"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="sm:inline-flex hidden tracking-[-0.6%] gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium tracking=[-0.6%] hover:underline"
|
||||
>
|
||||
{{ linkText }}
|
||||
<fluent-icon
|
||||
size="16"
|
||||
icon="chevron-right"
|
||||
type="outline"
|
||||
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mobile view for actions and link -->
|
||||
<div class="flex items-start justify-start w-full gap-3 sm:hidden">
|
||||
<slot name="actions" />
|
||||
<woot-button
|
||||
v-if="href && linkText"
|
||||
color-scheme="secondary"
|
||||
icon="arrow-outwards"
|
||||
class="flex-row-reverse rounded-xl min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
|
||||
@click="openInNewTab(href)"
|
||||
>
|
||||
{{ linkText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm sm:divide-x sm:divide-slate-75 sm:dark:divide-slate-700/50 group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 max-w-[900px] w-full"
|
||||
>
|
||||
<!-- left side section -->
|
||||
<slot name="leftSection">
|
||||
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
|
||||
<div
|
||||
class="flex items-center justify-between w-full gap-3 sm:justify-normal whitespace-nowrap"
|
||||
>
|
||||
<h3
|
||||
class="justify-between text-sm tracking-[-0.6%] font-medium truncate w-fit sm:justify-normal text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</h3>
|
||||
<slot name="label" />
|
||||
</div>
|
||||
<p
|
||||
class="text-sm text-slate-600 tracking-[0.5%] dark:text-slate-300 max-w-[400px] w-full line-clamp-2"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- right side section -->
|
||||
<slot name="rightSection" />
|
||||
|
||||
<!-- actions section -->
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="absolute flex-col items-center hidden gap-1 border-none ltr:-right-3 rtl:-left-3 top-3 group-hover:flex"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,7 +74,6 @@
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin],
|
||||
@@ -126,9 +125,10 @@ export default {
|
||||
});
|
||||
this.errorMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
|
||||
} catch (error) {
|
||||
this.errorMessage =
|
||||
parseAPIErrorResponse(error) ||
|
||||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
|
||||
this.errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
|
||||
if (error?.response?.data?.message) {
|
||||
this.errorMessage = error.response.data.message;
|
||||
}
|
||||
} finally {
|
||||
this.isPasswordChanging = false;
|
||||
this.showAlert(this.errorMessage);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<report-filter-selector
|
||||
:show-agents-filter="false"
|
||||
:show-group-by-filter="true"
|
||||
:show-business-hours-switch="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<bot-metrics :filters="requestPayload" />
|
||||
<report-container
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
:account-summary-key="'getBotSummary'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import BotMetrics from './components/BotMetrics.vue';
|
||||
import ReportFilterSelector from './components/FilterSelector.vue';
|
||||
import { GROUP_BY_FILTER } from './constants';
|
||||
import reportMixin from 'dashboard/mixins/reportMixin';
|
||||
import ReportContainer from './ReportContainer.vue';
|
||||
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'BotReports',
|
||||
components: {
|
||||
BotMetrics,
|
||||
ReportFilterSelector,
|
||||
ReportContainer,
|
||||
},
|
||||
mixins: [reportMixin],
|
||||
data() {
|
||||
return {
|
||||
from: 0,
|
||||
to: 0,
|
||||
groupBy: GROUP_BY_FILTER[1],
|
||||
reportKeys: {
|
||||
BOT_RESOLUTION_COUNT: 'bot_resolutions_count',
|
||||
BOT_HANDOFF_COUNT: 'bot_handoffs_count',
|
||||
},
|
||||
businessHours: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountReport: 'getAccountReports',
|
||||
}),
|
||||
requestPayload() {
|
||||
return {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchAllData() {
|
||||
this.fetchBotSummary();
|
||||
this.fetchChartData();
|
||||
},
|
||||
fetchBotSummary() {
|
||||
try {
|
||||
this.$store.dispatch('fetchBotSummary', this.getRequestPayload());
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
Object.keys(this.reportKeys).forEach(async key => {
|
||||
try {
|
||||
await this.$store.dispatch('fetchAccountReport', {
|
||||
metric: this.reportKeys[key],
|
||||
...this.getRequestPayload(),
|
||||
});
|
||||
} catch {
|
||||
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
|
||||
}
|
||||
});
|
||||
},
|
||||
getRequestPayload() {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
groupBy: groupBy?.period,
|
||||
businessHours,
|
||||
};
|
||||
},
|
||||
onFilterChange({ from, to, groupBy, businessHours }) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.groupBy = groupBy;
|
||||
this.businessHours = businessHours;
|
||||
this.fetchAllData();
|
||||
|
||||
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
|
||||
filterValue: { from, to, groupBy, businessHours },
|
||||
reportType: 'bots',
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
:key="metric.KEY"
|
||||
class="p-4 rounded-md mb-3"
|
||||
>
|
||||
<chart-stats :metric="metric" :account-summary-key="accountSummaryKey" />
|
||||
<chart-stats :metric="metric" />
|
||||
<div class="mt-4 h-72">
|
||||
<woot-loading-state
|
||||
v-if="accountReport.isFetching[metric.KEY]"
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import ReportMetricCard from './ReportMetricCard.vue';
|
||||
import ReportsAPI from 'dashboard/api/reports';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const conversationCount = ref('0');
|
||||
const messageCount = ref('0');
|
||||
const resolutionRate = ref('0');
|
||||
const handoffRate = ref('0');
|
||||
|
||||
const formatToPercent = value => {
|
||||
return value ? `${value}%` : '--';
|
||||
};
|
||||
|
||||
const fetchMetrics = () => {
|
||||
if (!props.filters.to || !props.filters.from) {
|
||||
return;
|
||||
}
|
||||
ReportsAPI.getBotMetrics(props.filters).then(response => {
|
||||
conversationCount.value = response.data.conversation_count.toLocaleString();
|
||||
messageCount.value = response.data.message_count.toLocaleString();
|
||||
resolutionRate.value = response.data.resolution_rate.toString();
|
||||
handoffRate.value = response.data.handoff_rate.toString();
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.filters, fetchMetrics, { deep: true });
|
||||
|
||||
onMounted(fetchMetrics);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||
>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
|
||||
:value="conversationCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="messageCount"
|
||||
class="flex-1"
|
||||
/>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(resolutionRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<report-metric-card
|
||||
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
|
||||
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(handoffRate)"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+2
-4
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div class="text-slate-900 dark:text-slate-100">
|
||||
<span class="text-sm">
|
||||
{{ metric.NAME }}
|
||||
</span>
|
||||
<div>
|
||||
<span class="text-sm">{{ metric.NAME }}</span>
|
||||
<div class="flex items-end">
|
||||
<div class="font-medium text-xl">
|
||||
{{ displayMetric(metric.KEY) }}
|
||||
|
||||
@@ -6,20 +6,17 @@
|
||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
|
||||
:value="responseCount"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:disabled="ratingFilterEnabled"
|
||||
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
|
||||
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
<csat-metric-card
|
||||
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
|
||||
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||
:value="formatToPercent(responseRate)"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ defineProps({
|
||||
<template>
|
||||
<div
|
||||
ref="reportMetricContainer"
|
||||
class="m-0 p-4"
|
||||
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%] m-0 p-4"
|
||||
:class="{
|
||||
'grayscale pointer-events-none opacity-30': disabled,
|
||||
}"
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
|
||||
exports[`CsatMetrics.vue computes response count correctly 1`] = `
|
||||
<div class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700">
|
||||
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" 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>
|
||||
<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>
|
||||
<!---->
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -194,8 +194,6 @@ export const METRIC_CHART = {
|
||||
reply_time: TIME_CHART_CONFIG,
|
||||
avg_resolution_time: TIME_CHART_CONFIG,
|
||||
resolutions_count: DEFAULT_CHART,
|
||||
bot_resolutions_count: DEFAULT_CHART,
|
||||
bot_handoffs_count: DEFAULT_CHART,
|
||||
};
|
||||
|
||||
export const OVERVIEW_METRICS = {
|
||||
|
||||
@@ -7,7 +7,6 @@ const LabelReports = () => import('./LabelReports.vue');
|
||||
const InboxReports = () => import('./InboxReports.vue');
|
||||
const TeamReports = () => import('./TeamReports.vue');
|
||||
const CsatResponses = () => import('./CsatResponses.vue');
|
||||
const BotReports = () => import('./BotReports.vue');
|
||||
const LiveReports = () => import('./LiveReports.vue');
|
||||
|
||||
export default {
|
||||
@@ -67,23 +66,6 @@ export default {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'BOT_REPORTS.HEADER',
|
||||
icon: 'bot',
|
||||
keepAlive: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'bot',
|
||||
name: 'bot_reports',
|
||||
roles: ['administrator'],
|
||||
component: BotReports,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/reports'),
|
||||
component: SettingsContent,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header :header-title="pageTitle" />
|
||||
<sla-form
|
||||
:submit-label="$t('SLA.FORM.EDIT')"
|
||||
:selected-response="selectedResponse"
|
||||
@submit="editSLA"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import validationMixin from './validationMixin';
|
||||
import validations from './validations';
|
||||
import SlaForm from './SlaForm.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SlaForm,
|
||||
},
|
||||
mixins: [alertMixin, validationMixin],
|
||||
props: {
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
validations,
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
pageTitle() {
|
||||
return `${this.$t('SLA.EDIT.TITLE')} - ${this.selectedResponse.name}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async editSLA(payload) {
|
||||
try {
|
||||
await this.$store.dispatch('sla/update', {
|
||||
id: this.selectedResponse.id,
|
||||
...payload,
|
||||
});
|
||||
this.showAlert(this.$t('SLA.EDIT.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || this.$t('SLA.EDIT.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -58,14 +58,14 @@
|
||||
</td>
|
||||
<td class="button-wrapper">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('SLA.FORM.DELETE')"
|
||||
v-tooltip.top="$t('SLA.FORM.EDIT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
color-scheme="secondary"
|
||||
class-names="grey-btn"
|
||||
:is-loading="loading[sla.id]"
|
||||
@click="openDeletePopup(sla)"
|
||||
icon="edit"
|
||||
@click="openEditPopup(sla)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -81,16 +81,9 @@
|
||||
<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"
|
||||
/>
|
||||
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
|
||||
<edit-SLA :selected-response="selectedResponse" @close="hideEditPopup" />
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
@@ -98,18 +91,20 @@ import { mapGetters } from 'vuex';
|
||||
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
|
||||
|
||||
import AddSLA from './AddSLA.vue';
|
||||
import EditSLA from './EditSLA.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddSLA,
|
||||
EditSLA,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
showAddPopup: false,
|
||||
showDeleteConfirmationPopup: false,
|
||||
showEditPopup: false,
|
||||
selectedResponse: {},
|
||||
};
|
||||
},
|
||||
@@ -118,15 +113,6 @@ export default {
|
||||
records: 'sla/getSLA',
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
deleteConfirmText() {
|
||||
return this.$t('SLA.DELETE.CONFIRM.YES');
|
||||
},
|
||||
deleteRejectText() {
|
||||
return this.$t('SLA.DELETE.CONFIRM.NO');
|
||||
},
|
||||
deleteMessage() {
|
||||
return ` ${this.selectedResponse.name}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('sla/get');
|
||||
@@ -138,30 +124,12 @@ export default {
|
||||
hideAddPopup() {
|
||||
this.showAddPopup = false;
|
||||
},
|
||||
openDeletePopup(response) {
|
||||
this.showDeleteConfirmationPopup = true;
|
||||
openEditPopup(response) {
|
||||
this.showEditPopup = true;
|
||||
this.selectedResponse = response;
|
||||
},
|
||||
closeDeletePopup() {
|
||||
this.showDeleteConfirmationPopup = false;
|
||||
},
|
||||
confirmDeletion() {
|
||||
this.loading[this.selectedResponse.id] = true;
|
||||
this.closeDeletePopup();
|
||||
this.deleteSla(this.selectedResponse.id);
|
||||
},
|
||||
deleteSla(id) {
|
||||
this.$store
|
||||
.dispatch('sla/delete', id)
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('SLA.DELETE.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(() => {
|
||||
this.showAlert(this.$t('SLA.DELETE.API.ERROR_MESSAGE'));
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading[this.selectedResponse.id] = false;
|
||||
});
|
||||
hideEditPopup() {
|
||||
this.showEditPopup = false;
|
||||
},
|
||||
displayTime(threshold) {
|
||||
const { time, unit } = convertSecondsToTimeUnit(threshold, {
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<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>
|
||||
@@ -1,23 +0,0 @@
|
||||
<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>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup>
|
||||
import BaseSettingsListItem from '../../components/BaseSettingsListItem.vue';
|
||||
import SLAResponseTime from './SLAResponseTime.vue';
|
||||
import SLABusinessHoursLabel from './SLABusinessHoursLabel.vue';
|
||||
|
||||
defineProps({
|
||||
slaName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstResponse: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
nextResponse: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
resolutionTime: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasBusinessHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<base-settings-list-item :title="slaName" :description="description">
|
||||
<template #label>
|
||||
<SLA-business-hours-label :has-business-hours="hasBusinessHours" />
|
||||
</template>
|
||||
<template #rightSection>
|
||||
<div
|
||||
class="flex items-center divide-x rtl:divide-x-reverse sm:rtl:!border-l-0 sm:rtl:!border-r sm:rtl:border-solid sm:rtl:border-slate-75 sm:rtl:dark:border-slate-700/50 gap-1.5 w-fit sm:w-full sm:gap-0 sm:justify-between divide-slate-75 dark:divide-slate-700/50"
|
||||
>
|
||||
<SLA-response-time response-type="FRT" :response-time="firstResponse" />
|
||||
<SLA-response-time response-type="NRT" :response-time="nextResponse" />
|
||||
<SLA-response-time response-type="RT" :response-time="resolutionTime" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('SLA.FORM.DELETE')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="delete"
|
||||
class-names="grey-btn"
|
||||
:is-loading="isLoading"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</template>
|
||||
</base-settings-list-item>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
responseType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
responseTime: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row items-start w-full h-full gap-1 sm:items-end sm:px-6 sm:py-2 sm:gap-2 sm:flex-col"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 tracking-[-0.6%] text-sm ltr:pl-1.5 sm:ltr:pl-0 rtl:pr-1.5 sm:rtl:pr-0 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.left="$t(`SLA.LIST.RESPONSE_TYPES.${responseType}`)"
|
||||
size="14"
|
||||
icon="information"
|
||||
type="outline"
|
||||
class="flex-shrink-0 hidden text-sm font-normal sm:flex sm:font-medium text-slate-500 dark:text-slate-500"
|
||||
/>
|
||||
{{ $t(`SLA.LIST.RESPONSE_TYPES.SHORT_HAND.${responseType}`) }}
|
||||
<span class="flex sm:hidden">:</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-sm sm:text-2xl font-medium tracking-[-1.5%] text-slate-900 dark:text-slate-25"
|
||||
>
|
||||
{{ responseTime }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,8 +19,6 @@ const state = {
|
||||
avg_first_response_time: false,
|
||||
avg_resolution_time: false,
|
||||
resolutions_count: false,
|
||||
bot_resolutions_count: false,
|
||||
bot_handoffs_count: false,
|
||||
reply_time: false,
|
||||
},
|
||||
data: {
|
||||
@@ -30,8 +28,6 @@ const state = {
|
||||
avg_first_response_time: [],
|
||||
avg_resolution_time: [],
|
||||
resolutions_count: [],
|
||||
bot_resolutions_count: [],
|
||||
bot_handoffs_count: [],
|
||||
reply_time: [],
|
||||
},
|
||||
},
|
||||
@@ -43,13 +39,6 @@ const state = {
|
||||
outgoing_messages_count: 0,
|
||||
reply_time: 0,
|
||||
resolutions_count: 0,
|
||||
bot_resolutions_count: 0,
|
||||
bot_handoffs_count: 0,
|
||||
previous: {},
|
||||
},
|
||||
botSummary: {
|
||||
bot_resolutions_count: 0,
|
||||
bot_handoffs_count: 0,
|
||||
previous: {},
|
||||
},
|
||||
overview: {
|
||||
@@ -71,9 +60,6 @@ const getters = {
|
||||
getAccountSummary(_state) {
|
||||
return _state.accountSummary;
|
||||
},
|
||||
getBotSummary(_state) {
|
||||
return _state.botSummary;
|
||||
},
|
||||
getAccountConversationMetric(_state) {
|
||||
return _state.overview.accountConversationMetric;
|
||||
},
|
||||
@@ -139,20 +125,6 @@ export const actions = {
|
||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||
});
|
||||
},
|
||||
fetchBotSummary({ commit }, reportObj) {
|
||||
Report.getBotSummary({
|
||||
from: reportObj.from,
|
||||
to: reportObj.to,
|
||||
groupBy: reportObj.groupBy,
|
||||
businessHours: reportObj.businessHours,
|
||||
})
|
||||
.then(botSummary => {
|
||||
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
|
||||
});
|
||||
},
|
||||
fetchAccountConversationMetric({ commit }, reportObj) {
|
||||
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
|
||||
Report.getConversationMetric(reportObj.type)
|
||||
@@ -271,9 +243,6 @@ const mutations = {
|
||||
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
||||
_state.accountSummary = summaryData;
|
||||
},
|
||||
[types.default.SET_BOT_SUMMARY](_state, summaryData) {
|
||||
_state.botSummary = summaryData;
|
||||
},
|
||||
[types.default.SET_ACCOUNT_CONVERSATION_METRIC](_state, metricData) {
|
||||
_state.overview.accountConversationMetric = metricData;
|
||||
},
|
||||
|
||||
@@ -50,16 +50,16 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
delete: async function deleteSla({ commit }, id) {
|
||||
commit(types.SET_SLA_UI_FLAG, { isDeleting: true });
|
||||
update: async function update({ commit }, { id, ...updateObj }) {
|
||||
commit(types.SET_SLA_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
await SlaAPI.delete(id);
|
||||
AnalyticsHelper.track(SLA_EVENTS.DELETED);
|
||||
commit(types.DELETE_SLA, id);
|
||||
const response = await SlaAPI.update(id, updateObj);
|
||||
AnalyticsHelper.track(SLA_EVENTS.UPDATE);
|
||||
commit(types.EDIT_SLA, response.data.payload);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.SET_SLA_UI_FLAG, { isDeleting: false });
|
||||
commit(types.SET_SLA_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const mutations = {
|
||||
|
||||
[types.SET_SLA]: MutationHelpers.set,
|
||||
[types.ADD_SLA]: MutationHelpers.create,
|
||||
[types.DELETE_SLA]: MutationHelpers.destroy,
|
||||
[types.EDIT_SLA]: MutationHelpers.update,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -166,7 +166,6 @@ export default {
|
||||
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
|
||||
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
|
||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
||||
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
||||
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
|
||||
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
v-else-if="showIcon"
|
||||
:size="iconSize"
|
||||
:icon="icon"
|
||||
class="flex-shrink-0"
|
||||
:class="className"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
"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",
|
||||
@@ -11,7 +9,6 @@
|
||||
"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",
|
||||
@@ -121,7 +118,6 @@
|
||||
"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",
|
||||
@@ -170,7 +166,6 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
// Array defining days of the week
|
||||
const daysOfWeek = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
/**
|
||||
* Find the next available time for the specified day
|
||||
* @param {Object[]} workingHours - Array of objects representing working hours for each day
|
||||
* @param {number} dayOfWeek - Index of the day of the week (0 for Sunday, 1 for Monday, etc.)
|
||||
* @param {number} currentHour - Current hour of the day (24-hour format)
|
||||
* @param {number} currentMinutes - Current minutes of the hour
|
||||
* @returns {Object|null} - Object containing next available time information or null if not available
|
||||
*/
|
||||
const findNextAvailableTimeForDay = (
|
||||
workingHours,
|
||||
dayOfWeek,
|
||||
currentHour,
|
||||
currentMinutes
|
||||
) => {
|
||||
const availableHours = workingHours.find(
|
||||
hours =>
|
||||
hours.day_of_week === dayOfWeek &&
|
||||
!hours.closed_all_day &&
|
||||
(currentHour < hours.close_hour ||
|
||||
(currentHour === hours.close_hour &&
|
||||
currentMinutes < hours.close_minutes))
|
||||
);
|
||||
|
||||
if (availableHours) {
|
||||
return {
|
||||
hour: availableHours.open_hour,
|
||||
minutes: availableHours.open_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the next available time across days
|
||||
* @param {Object[]} workingHours - Array of objects representing working hours for each day
|
||||
* @param {number} currentDayOfWeek - Index of the current day of the week (0 for Sunday, 1 for Monday, etc.)
|
||||
* @returns {Object|null} - Object containing next available time information or null if not available
|
||||
*/
|
||||
const findNextAvailableTimeAcrossDays = (workingHours, currentDayOfWeek) => {
|
||||
const nextAvailableDays = workingHours.filter(hours => !hours.closed_all_day);
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const nextDayIndex = (currentDayOfWeek + 1 + i) % 7;
|
||||
const nextDay = nextAvailableDays.find(
|
||||
hours => hours.day_of_week === nextDayIndex
|
||||
);
|
||||
if (nextDay) {
|
||||
return {
|
||||
hour: nextDay.open_hour,
|
||||
minutes: nextDay.open_minutes,
|
||||
day: nextDayIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to find the next available time
|
||||
* @param {Array} workingHours - Array of working hours for each day
|
||||
* @param {number} currentDayOfWeek - Index of the current day of the week (0 for Sunday, 1 for Monday, etc.)
|
||||
* @param {number} currentHour - Current hour of the day (24-hour format)
|
||||
* @param {number} currentMinutes - Current minutes of the hour
|
||||
* @returns {Object|null} - Object containing next available time information or null if not available
|
||||
*/
|
||||
const findNextAvailableTime = (
|
||||
workingHours,
|
||||
currentDayOfWeek,
|
||||
currentHour,
|
||||
currentMinutes
|
||||
) => {
|
||||
const nextAvailableTimeToday = findNextAvailableTimeForDay(
|
||||
workingHours,
|
||||
currentDayOfWeek,
|
||||
currentHour,
|
||||
currentMinutes
|
||||
);
|
||||
|
||||
if (nextAvailableTimeToday) {
|
||||
return { ...nextAvailableTimeToday, day: 'today' };
|
||||
}
|
||||
return findNextAvailableTimeAcrossDays(workingHours, currentDayOfWeek);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get the next availability message
|
||||
* @param {Array} workingHours - Array of working hours for each day
|
||||
* @param {Date} currentTime - Current time object
|
||||
* @returns {string} - Next availability message
|
||||
*/
|
||||
export const getNextAvailabilityMessage = (
|
||||
workingHours,
|
||||
currentTime,
|
||||
timezone
|
||||
) => {
|
||||
const utcZonedTime = utcToZonedTime(currentTime, timezone);
|
||||
const currentDayOfWeek = utcZonedTime.getDay();
|
||||
const currentHour = utcZonedTime.getHours();
|
||||
const currentMinutes = utcZonedTime.getMinutes();
|
||||
const nextAvailableTime = findNextAvailableTime(
|
||||
workingHours,
|
||||
currentDayOfWeek,
|
||||
currentHour,
|
||||
currentMinutes
|
||||
);
|
||||
|
||||
// Calculate the time difference
|
||||
if (nextAvailableTime !== null) {
|
||||
const differenceInMinutes =
|
||||
nextAvailableTime.hour * 60 +
|
||||
nextAvailableTime.minutes -
|
||||
(currentHour * 60 + currentMinutes);
|
||||
const differenceInHours = Math.floor(differenceInMinutes / 60);
|
||||
|
||||
// Check if the next available time is tomorrow
|
||||
if (nextAvailableTime.day === (currentDayOfWeek + 1) % 7) {
|
||||
return `We will be back online tomorrow`;
|
||||
}
|
||||
|
||||
// Check if the next available time is on a specific day
|
||||
if (nextAvailableTime.day !== 'today') {
|
||||
return `We will be back online on ${daysOfWeek[nextAvailableTime.day]}`;
|
||||
}
|
||||
|
||||
// Generate appropriate message based on time difference
|
||||
if (differenceInMinutes <= 1) {
|
||||
return 'We will be back online in less than a minute.';
|
||||
}
|
||||
if (differenceInMinutes < 60) {
|
||||
return `We will be back online in ${differenceInMinutes} minutes.`;
|
||||
}
|
||||
if (differenceInMinutes < 1440) {
|
||||
return `We will be back online in ${differenceInHours} hours`;
|
||||
}
|
||||
}
|
||||
return 'No available time found in working hours.';
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import { getNextAvailabilityMessage } from '../availabilityHelper';
|
||||
|
||||
const workingHoursCommon = [
|
||||
{ id: 8, inbox_id: 2, account_id: 1, day_of_week: 0, closed_all_day: true },
|
||||
{
|
||||
id: 9,
|
||||
inbox_id: 2,
|
||||
account_id: 1,
|
||||
day_of_week: 1,
|
||||
closed_all_day: false,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
inbox_id: 2,
|
||||
account_id: 1,
|
||||
day_of_week: 2,
|
||||
closed_all_day: true,
|
||||
open_hour: 15,
|
||||
open_minutes: 0,
|
||||
close_hour: 23,
|
||||
close_minutes: 30,
|
||||
},
|
||||
{ id: 11, inbox_id: 2, account_id: 1, day_of_week: 3, closed_all_day: true },
|
||||
{
|
||||
id: 12,
|
||||
inbox_id: 2,
|
||||
account_id: 1,
|
||||
day_of_week: 4,
|
||||
closed_all_day: false,
|
||||
open_hour: 15,
|
||||
open_minutes: 0,
|
||||
close_hour: 20,
|
||||
close_minutes: 0,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
inbox_id: 2,
|
||||
account_id: 1,
|
||||
day_of_week: 5,
|
||||
closed_all_day: false,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
inbox_id: 2,
|
||||
account_id: 1,
|
||||
day_of_week: 6,
|
||||
closed_all_day: false,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 7,
|
||||
close_minutes: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe('getNextAvailabilityMessage without timezone', () => {
|
||||
let workingHours;
|
||||
|
||||
beforeEach(() => {
|
||||
workingHours = JSON.parse(JSON.stringify(workingHoursCommon));
|
||||
});
|
||||
|
||||
test('less than a minute', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T14:59:59'))
|
||||
).toBe('We will be back online in less than a minute.'));
|
||||
test('less than 10 minutes', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T14:50:00'))
|
||||
).toBe('We will be back online in 10 minutes.'));
|
||||
test('minutes message for less than an hour', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T14:45:00'))
|
||||
).toBe('We will be back online in 15 minutes.'));
|
||||
test('greater than 2 hours', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T12:30:00'))
|
||||
).toBe('We will be back online in 2 hours'));
|
||||
test('next day', () => {
|
||||
workingHours[4].closed_all_day = true;
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T00:00:00'))
|
||||
).toBe('We will be back online tomorrow');
|
||||
});
|
||||
test('specific day', () => {
|
||||
workingHours[4].closed_all_day = true;
|
||||
workingHours[5].closed_all_day = true;
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-14T00:00:00'))
|
||||
).toBe('We will be back online on Saturday');
|
||||
});
|
||||
test('next week', () => {
|
||||
workingHours[5].closed_all_day = true;
|
||||
workingHours[6].closed_all_day = true;
|
||||
workingHours[0].closed_all_day = true;
|
||||
workingHours[1].closed_all_day = true;
|
||||
workingHours[2].closed_all_day = true;
|
||||
workingHours[3].closed_all_day = true;
|
||||
workingHours[4].closed_all_day = false;
|
||||
expect(
|
||||
getNextAvailabilityMessage(workingHours, new Date('2024-03-21T21:00:00'))
|
||||
).toBe('We will be back online on Thursday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextAvailabilityMessage with timezone', () => {
|
||||
let workingHours;
|
||||
|
||||
beforeEach(() => {
|
||||
workingHours = JSON.parse(JSON.stringify(workingHoursCommon));
|
||||
jest.useFakeTimers('modern');
|
||||
jest.setSystemTime(new Date('2024-03-14T19:50:00'));
|
||||
});
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
test('less than a minute', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(
|
||||
workingHours,
|
||||
new Date('2024-03-14T19:50:00'),
|
||||
'America/New_York'
|
||||
)
|
||||
).toBe('We will be back online in less than a minute.'));
|
||||
test('less than 10 minutes', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(
|
||||
workingHours,
|
||||
new Date('2024-03-14T19:50:00'),
|
||||
'America/New_York'
|
||||
)
|
||||
).toBe('We will be back online in less than a minute.'));
|
||||
test('greater than 2 hours', () =>
|
||||
expect(
|
||||
getNextAvailabilityMessage(
|
||||
workingHours,
|
||||
new Date('2024-03-14T14:59:59'),
|
||||
'America/New_York'
|
||||
)
|
||||
).toBe('We will be back online in 4 hours'));
|
||||
test('next day', () => {
|
||||
workingHours[4].closed_all_day = true;
|
||||
expect(
|
||||
getNextAvailabilityMessage(
|
||||
workingHours,
|
||||
new Date('2024-03-15T00:00:00'),
|
||||
'America/New_York'
|
||||
)
|
||||
).toBe('We will be back online tomorrow');
|
||||
});
|
||||
test('specific day', () => {
|
||||
workingHours[4].closed_all_day = true;
|
||||
workingHours[5].closed_all_day = true;
|
||||
expect(
|
||||
getNextAvailabilityMessage(
|
||||
workingHours,
|
||||
new Date('2024-03-17T00:00:00'),
|
||||
'America/New_York'
|
||||
)
|
||||
).toBe('We will be back online on Monday');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { isTimeAfter } from 'shared/helpers/DateHelper';
|
||||
|
||||
import { getNextAvailabilityMessage } from 'widget/helpers/availabilityHelper';
|
||||
export default {
|
||||
computed: {
|
||||
channelConfig() {
|
||||
@@ -23,10 +23,13 @@ export default {
|
||||
},
|
||||
replyWaitMessage() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const nextAvailabilityMessage = getNextAvailabilityMessage(
|
||||
this.channelConfig.workingHours,
|
||||
new Date(),
|
||||
this.channelConfig.timezone
|
||||
);
|
||||
if (workingHoursEnabled) {
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
|
||||
return this.isOnline ? this.replyTimeStatus : nextAvailabilityMessage;
|
||||
}
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
|
||||
@@ -12,10 +12,10 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
process_email_for_channel(channel)
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
Rails.logger.error e
|
||||
channel.authorization_error!
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e
|
||||
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
Rails.logger.error e
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
rescue StandardError => e
|
||||
|
||||
@@ -61,7 +61,7 @@ class Contact < ApplicationRecord
|
||||
after_create_commit :dispatch_create_event, :ip_lookup
|
||||
after_update_commit :dispatch_update_event
|
||||
after_destroy_commit :dispatch_destroy_event
|
||||
before_save :sync_contact_attributes
|
||||
before_save :update_contact_location_and_country_code
|
||||
|
||||
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
|
||||
|
||||
@@ -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
|
||||
@@ -207,8 +207,11 @@ class Contact < ApplicationRecord
|
||||
self.custom_attributes = {} if custom_attributes.blank?
|
||||
end
|
||||
|
||||
def sync_contact_attributes
|
||||
::Contacts::SyncAttributes.new(self).perform
|
||||
def update_contact_location_and_country_code
|
||||
# TODO: Ensure that location and country_code are updated from additional_attributes.
|
||||
# We will remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
|
||||
self.location = additional_attributes['city']
|
||||
self.country_code = additional_attributes['country']
|
||||
end
|
||||
|
||||
def dispatch_create_event
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.from_email(email)
|
||||
find_by(email: email&.downcase)
|
||||
find_by(email: email.downcase)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,8 +5,8 @@ class AutomationRules::ConditionValidationService
|
||||
@rule = rule
|
||||
@account = rule.account
|
||||
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
|
||||
@@ -11,9 +11,8 @@ class AutomationRules::ConditionsFilterService < FilterService
|
||||
@account = conversation.account
|
||||
|
||||
# setup filters from json file
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
@conversation_filters = @filters['conversations']
|
||||
@contact_filters = @filters['contacts']
|
||||
@message_filters = @filters['messages']
|
||||
|
||||
@@ -2,7 +2,7 @@ class Contacts::FilterService < FilterService
|
||||
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
|
||||
|
||||
def perform
|
||||
@contacts = query_builder(@filters['contacts'])
|
||||
@contacts = contact_query_builder
|
||||
|
||||
{
|
||||
contacts: @contacts,
|
||||
@@ -10,6 +10,38 @@ 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'
|
||||
@@ -25,13 +57,6 @@ 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)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
class Contacts::SyncAttributes
|
||||
attr_reader :contact
|
||||
|
||||
def initialize(contact)
|
||||
@contact = contact
|
||||
end
|
||||
|
||||
def perform
|
||||
update_contact_location_and_country_code
|
||||
set_contact_type
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_contact_location_and_country_code
|
||||
# Ensure that location and country_code are updated from additional_attributes.
|
||||
# TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
|
||||
@contact.location = @contact.additional_attributes['city']
|
||||
@contact.country_code = @contact.additional_attributes['country']
|
||||
end
|
||||
|
||||
def set_contact_type
|
||||
# If the contact is already a lead or customer then do not change the contact type
|
||||
return unless @contact.contact_type == 'visitor'
|
||||
# If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead
|
||||
# If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead
|
||||
return unless @contact.email.present? || @contact.phone_number.present? || social_details_present?
|
||||
|
||||
@contact.contact_type = 'lead'
|
||||
end
|
||||
|
||||
def social_details_present?
|
||||
@contact.additional_attributes.keys.any? do |key|
|
||||
key.start_with?('social_') && @contact.additional_attributes[key].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ class Conversations::FilterService < FilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
@conversations = query_builder(@filters['conversations'])
|
||||
@conversations = conversation_query_builder
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
@@ -22,6 +22,37 @@ 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
|
||||
@@ -32,13 +63,6 @@ class Conversations::FilterService < FilterService
|
||||
@params[:page] || 1
|
||||
end
|
||||
|
||||
def filter_config
|
||||
{
|
||||
entity: 'Conversation',
|
||||
table_name: 'conversations'
|
||||
}
|
||||
end
|
||||
|
||||
def conversations
|
||||
@conversations.sort_on_last_activity_at.page(current_page)
|
||||
end
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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'
|
||||
@@ -12,8 +9,8 @@ class FilterService
|
||||
def initialize(params, user)
|
||||
@params = params
|
||||
@user = user
|
||||
file = File.read('./lib/filters/filter_keys.yml')
|
||||
@filters = YAML.safe_load(file)
|
||||
file = File.read('./lib/filters/filter_keys.json')
|
||||
@filters = JSON.parse(file)
|
||||
@query_string = ''
|
||||
@filter_values = {}
|
||||
end
|
||||
@@ -109,9 +106,7 @@ class FilterService
|
||||
]
|
||||
end
|
||||
|
||||
def tag_filter_query(query_hash, current_index)
|
||||
model_name = filter_config[:entity]
|
||||
table_name = filter_config[:table_name]
|
||||
def tag_filter_query(model_name, table_name, query_hash, current_index)
|
||||
query_operator = query_hash[:query_operator]
|
||||
@filter_values["value_#{current_index}"] = filter_values(query_hash)
|
||||
|
||||
@@ -135,8 +130,10 @@ 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
|
||||
@@ -158,9 +155,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)
|
||||
@@ -197,11 +194,4 @@ class FilterService
|
||||
|
||||
"NOT LIKE :value_#{current_index}"
|
||||
end
|
||||
|
||||
def query_builder(model_filters)
|
||||
@params[:payload].each_with_index do |query_hash, current_index|
|
||||
@query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}"
|
||||
end
|
||||
base_relation.where(@query_string, @filter_values.with_indifferent_access)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -130,7 +130,6 @@ class Telegram::IncomingMessageService
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: :location,
|
||||
fallback_title: location_fallback_title,
|
||||
coordinates_lat: location['latitude'],
|
||||
coordinates_long: location['longitude']
|
||||
)
|
||||
@@ -140,16 +139,6 @@ class Telegram::IncomingMessageService
|
||||
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
|
||||
end
|
||||
|
||||
def location_fallback_title
|
||||
return '' if venue.blank?
|
||||
|
||||
venue[:title] || ''
|
||||
end
|
||||
|
||||
def venue
|
||||
@venue ||= params.dig(:message, :venue).presence
|
||||
end
|
||||
|
||||
def location
|
||||
@location ||= params.dig(:message, :location).presence
|
||||
end
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/conversations/partials/conversation', formats: [:json], conversation: @conversation
|
||||
@@ -47,4 +47,3 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat
|
||||
json.last_activity_at conversation.last_activity_at.to_i
|
||||
json.priority conversation.priority
|
||||
json.waiting_since conversation.waiting_since.to_i.to_i
|
||||
json.sla_policy_id conversation.sla_policy_id
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
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 %>
|
||||
|
||||
@@ -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 == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= render partial: 'icons/chevron-down' %>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.7.0'
|
||||
version: '3.6.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+1
-12
@@ -3,45 +3,37 @@
|
||||
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
|
||||
@@ -52,10 +44,8 @@
|
||||
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
|
||||
@@ -72,7 +62,6 @@
|
||||
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
|
||||
|
||||
@@ -77,9 +77,7 @@ 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
|
||||
|
||||
+1
-6
@@ -78,7 +78,7 @@ Rails.application.routes.draw do
|
||||
namespace :channels do
|
||||
resource :twilio_channel, only: [:create]
|
||||
end
|
||||
resources :conversations, only: [:index, :create, :show, :update] do
|
||||
resources :conversations, only: [:index, :create, :show] do
|
||||
collection do
|
||||
get :meta
|
||||
get :search
|
||||
@@ -144,11 +144,6 @@ Rails.application.routes.draw do
|
||||
get :download
|
||||
end
|
||||
end
|
||||
resources :applied_slas, only: [:index] do
|
||||
collection do
|
||||
get :metrics
|
||||
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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
+1
-18
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -842,23 +842,6 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) 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"
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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]
|
||||
before_action :set_current_page, only: [:index]
|
||||
before_action :set_current_page_appiled_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
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
@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 initial_query
|
||||
Current.account.applied_slas.includes(:conversation)
|
||||
end
|
||||
|
||||
def set_current_page_appiled_slas
|
||||
@applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
def permitted_update_params
|
||||
super.merge(params.permit(:sla_policy_id))
|
||||
end
|
||||
end
|
||||
@@ -22,27 +22,7 @@ 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 }
|
||||
|
||||
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
|
||||
self.account_id ||= sla_policy&.account_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,35 +3,5 @@ module Enterprise::EnterpriseConversationConcern
|
||||
|
||||
included do
|
||||
belongs_to :sla_policy, optional: true
|
||||
has_one :applied_sla, dependent: :destroy
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_sla_policy
|
||||
# TODO: remove these validations once we figure out how to deal with these cases
|
||||
if sla_policy_id.nil? && changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'cannot remove sla policy from conversation')
|
||||
return
|
||||
end
|
||||
|
||||
if changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'conversation already has a different sla')
|
||||
return
|
||||
end
|
||||
|
||||
errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id
|
||||
end
|
||||
|
||||
# handling inside a transaction to ensure applied sla record is also created
|
||||
def ensure_applied_sla_is_created
|
||||
ActiveRecord::Base.transaction do
|
||||
yield
|
||||
create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank?
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: sla_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# event_type :integer
|
||||
# meta :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# applied_sla_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# sla_policy_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_sla_events_on_account_id (account_id)
|
||||
# index_sla_events_on_applied_sla_id (applied_sla_id)
|
||||
# index_sla_events_on_conversation_id (conversation_id)
|
||||
# index_sla_events_on_inbox_id (inbox_id)
|
||||
# index_sla_events_on_sla_policy_id (sla_policy_id)
|
||||
#
|
||||
class SlaEvent < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :sla_policy
|
||||
belongs_to :applied_sla
|
||||
|
||||
enum event_type: { frt: 0, nrt: 1, rt: 2 }
|
||||
|
||||
before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id
|
||||
|
||||
private
|
||||
|
||||
def ensure_applied_sla_id
|
||||
self.applied_sla_id ||= AppliedSla.find_by(conversation_id: conversation_id)&.last&.id
|
||||
end
|
||||
|
||||
def ensure_account_id
|
||||
self.account_id ||= conversation&.account_id
|
||||
end
|
||||
|
||||
def ensure_inbox_id
|
||||
self.inbox_id ||= conversation&.inbox_id
|
||||
end
|
||||
|
||||
def ensure_sla_policy_id
|
||||
self.sla_policy_id ||= applied_sla&.sla_policy_id
|
||||
end
|
||||
end
|
||||
@@ -22,7 +22,6 @@ class SlaPolicy < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
|
||||
has_many :conversations, dependent: :nullify
|
||||
has_many :applied_slas, dependent: :destroy
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
|
||||
@@ -8,5 +8,16 @@ module Enterprise::ActionService
|
||||
|
||||
Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}"
|
||||
@conversation.update!(sla_policy_id: sla_policy.id)
|
||||
create_applied_sla(sla_policy)
|
||||
end
|
||||
|
||||
def create_applied_sla(sla_policy)
|
||||
Rails.logger.info "SLA:: Creating Applied SLA for conversation: #{@conversation.id}"
|
||||
AppliedSla.create!(
|
||||
account_id: @conversation.account_id,
|
||||
sla_policy_id: sla_policy.id,
|
||||
conversation_id: @conversation.id,
|
||||
sla_status: 'active'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
json.total_applied_slas @total_applied_slas
|
||||
json.number_of_sla_breaches @number_of_sla_breaches
|
||||
json.hit_rate @hit_rate
|
||||
@@ -1,5 +0,0 @@
|
||||
json.id sla_event.id
|
||||
json.event_type sla_event.event_type
|
||||
json.meta sla_event.meta
|
||||
json.updated_at sla_event.updated_at.to_i
|
||||
json.created_at sla_event.created_at.to_i
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
## This file contains the filter configurations which we use for the following
|
||||
# 1. Conversation Filters (app/services/filter_service.rb)
|
||||
# 2. Contact Filters (app/services/filter_service.rb)
|
||||
# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb)
|
||||
|
||||
|
||||
# Format
|
||||
# - Parent Key (conversation, contact, messages)
|
||||
# - Key (attribute_name)
|
||||
# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"]
|
||||
# - data_type: "text" : supported ["text", "text_case_insensitive", "number", "boolean", "labels", "date", "link"]
|
||||
# - filter_operators: ["equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present", "is_greater_than", "is_less_than", "days_before", "starts_with"]
|
||||
|
||||
### ----- Conversation Filters ----- ###
|
||||
|
||||
conversations:
|
||||
status:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
assignee_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
inbox_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
team_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
display_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "Number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
campaign_id:
|
||||
attribute_type: "standard"
|
||||
data_type: "Number"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
labels:
|
||||
attribute_type: "standard"
|
||||
data_type: "labels"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
browser_language:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
conversation_language:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
country_code:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
referer:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "link"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
created_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
last_activity_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
mail_subject:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
|
||||
### ----- End of Conversation Filters ----- ###
|
||||
|
||||
|
||||
### ----- Contact Filters ----- ###
|
||||
contacts:
|
||||
name:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
phone_number:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
- "starts_with"
|
||||
email:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
identifier:
|
||||
attribute_type: "standard"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
country_code:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
city:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
company:
|
||||
attribute_type: "additional_attributes"
|
||||
data_type: "text_case_insensitive"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
labels:
|
||||
attribute_type: "standard"
|
||||
data_type: "labels"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "is_present"
|
||||
- "is_not_present"
|
||||
created_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
last_activity_at:
|
||||
attribute_type: "standard"
|
||||
data_type: "date"
|
||||
filter_operators:
|
||||
- "is_greater_than"
|
||||
- "is_less_than"
|
||||
- "days_before"
|
||||
blocked:
|
||||
attribute_type: "standard"
|
||||
data_type: "boolean"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
|
||||
### ----- End of Contact Filters ----- ###
|
||||
|
||||
### ----- Message Filters ----- ###
|
||||
messages:
|
||||
message_type:
|
||||
attribute_type: "standard"
|
||||
data_type: "numeric"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
content:
|
||||
attribute_type: "standard"
|
||||
data_type: "text"
|
||||
filter_operators:
|
||||
- "equal_to"
|
||||
- "not_equal_to"
|
||||
- "contains"
|
||||
- "does_not_contain"
|
||||
|
||||
### ----- End of Message Filters ----- ###
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "3.7.0",
|
||||
"version": "3.6.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -338,18 +338,14 @@ 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, 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
|
||||
let!(:contact1) { create(:contact, :with_email, account: account) }
|
||||
let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') }
|
||||
|
||||
it 'returns all contacts when query is empty' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/filter",
|
||||
params: { payload: [
|
||||
attribute_key: 'country_code',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['US']
|
||||
] },
|
||||
params: {
|
||||
payload: []
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -357,34 +353,6 @@ 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,56 +152,17 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'returns all conversations matching the query' do
|
||||
it 'returns all conversations with empty query' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/filter",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: {
|
||||
payload: [{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['open']
|
||||
}]
|
||||
},
|
||||
params: { payload: [] },
|
||||
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
|
||||
|
||||
@@ -249,55 +210,6 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:params) { { priority: 'high' } }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'does not update the conversation if you do not have access to it' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an administrator' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an agent with access to inbox' do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/conversations' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
@@ -499,6 +411,21 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(conversation.reload.status).to eq('snoozed')
|
||||
expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until)
|
||||
end
|
||||
|
||||
# TODO: remove this spec when we remove the condition check in controller
|
||||
# Added for backwards compatibility for bot status
|
||||
# remove in next release
|
||||
# it 'toggles the conversation status to pending status when parameter bot is passed' do
|
||||
# expect(conversation.status).to eq('open')
|
||||
|
||||
# post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
# headers: agent.create_new_auth_token,
|
||||
# params: { status: 'bot' },
|
||||
# as: :json
|
||||
|
||||
# expect(response).to have_http_status(:success)
|
||||
# expect(conversation.reload.status).to eq('pending')
|
||||
# end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated bot' do
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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' 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
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Conversations API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:params) { { sla_policy_id: sla_policy.id } }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an agent with access to inbox' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id)
|
||||
end
|
||||
|
||||
it 'throws error if conversation already has a different sla' do
|
||||
conversation.update(sla_policy: create(:sla_policy, account: account))
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,10 +7,10 @@ RSpec.describe Conversation, type: :model do
|
||||
|
||||
describe 'SLA policy updates' do
|
||||
let!(:conversation) { create(:conversation) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: conversation.account) }
|
||||
let!(:sla_policy) { create(:sla_policy) }
|
||||
|
||||
it 'generates an activity message when the SLA policy is updated' do
|
||||
conversation.update!(sla_policy_id: sla_policy.id)
|
||||
conversation.update(sla_policy_id: sla_policy.id)
|
||||
|
||||
perform_enqueued_jobs
|
||||
|
||||
@@ -21,19 +21,18 @@ RSpec.describe Conversation, type: :model do
|
||||
expect(activity_message.content).to include('added SLA policy')
|
||||
end
|
||||
|
||||
# TODO: Reenable this when we let the SLA policy be removed from a conversation
|
||||
# it 'generates an activity message when the SLA policy is removed' do
|
||||
# conversation.update!(sla_policy_id: sla_policy.id)
|
||||
# conversation.update!(sla_policy_id: nil)
|
||||
it 'generates an activity message when the SLA policy is removed' do
|
||||
conversation.update(sla_policy_id: sla_policy.id)
|
||||
conversation.update(sla_policy_id: nil)
|
||||
|
||||
# perform_enqueued_jobs
|
||||
perform_enqueued_jobs
|
||||
|
||||
# activity_message = conversation.messages.where(message_type: 'activity').last
|
||||
activity_message = conversation.messages.where(message_type: 'activity').last
|
||||
|
||||
# expect(activity_message).not_to be_nil
|
||||
# expect(activity_message.message_type).to eq('activity')
|
||||
# expect(activity_message.content).to include('removed SLA policy')
|
||||
# end
|
||||
expect(activity_message).not_to be_nil
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to include('removed SLA policy')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation sentiments' do
|
||||
@@ -65,43 +64,4 @@ RSpec.describe Conversation, type: :model do
|
||||
expect(sentiments[:label]).to eq('positive')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policy' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:different_account_sla_policy) { create(:sla_policy) }
|
||||
|
||||
context 'when sla_policy is getting updated' do
|
||||
it 'throws error if sla policy belongs to different account' do
|
||||
conversation.sla_policy = different_account_sla_policy
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch')
|
||||
end
|
||||
|
||||
it 'creates applied sla record if sla policy is present' do
|
||||
conversation.sla_policy = sla_policy
|
||||
conversation.save!
|
||||
expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation already has a different sla' do
|
||||
before do
|
||||
conversation.update(sla_policy: create(:sla_policy, account: account))
|
||||
end
|
||||
|
||||
it 'throws error if trying to assing a different sla' do
|
||||
conversation.sla_policy = sla_policy
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla'])
|
||||
end
|
||||
|
||||
it 'throws error if trying to set sla to nil' do
|
||||
conversation.sla_policy = nil
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SlaEvent, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:applied_sla) }
|
||||
it { is_expected.to belong_to(:conversation) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:sla_policy) }
|
||||
it { is_expected.to belong_to(:inbox) }
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid sla event object' do
|
||||
sla_event = create(:sla_event)
|
||||
expect(sla_event.event_type).to eq 'frt'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'backfilling ids' do
|
||||
it 'automatically backfills account_id, inbox_id, and sla_id upon creation' do
|
||||
sla_event = create(:sla_event)
|
||||
|
||||
expect(sla_event.account_id).to eq sla_event.conversation.account_id
|
||||
expect(sla_event.inbox_id).to eq sla_event.conversation.inbox_id
|
||||
expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,21 +5,14 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
|
||||
let!(:user_1) { create(:user, account: account) }
|
||||
let!(:user_2) { create(:user, account: account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
let!(:conversation) { create(:conversation, created_at: 6.hours.ago, assignee: user_1, account: account) }
|
||||
let!(:sla_policy) do
|
||||
create(:sla_policy,
|
||||
account: account,
|
||||
first_response_time_threshold: nil,
|
||||
next_response_time_threshold: nil,
|
||||
resolution_time_threshold: nil)
|
||||
create(:sla_policy, account: conversation.account,
|
||||
first_response_time_threshold: nil,
|
||||
next_response_time_threshold: nil,
|
||||
resolution_time_threshold: nil)
|
||||
end
|
||||
let!(:conversation) do
|
||||
create(:conversation,
|
||||
created_at: 6.hours.ago, assignee: user_1,
|
||||
account: sla_policy.account,
|
||||
sla_policy: sla_policy)
|
||||
end
|
||||
let!(:applied_sla) { conversation.applied_sla }
|
||||
let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') }
|
||||
|
||||
describe '#perform - SLA misses' do
|
||||
context 'when first response SLA is missed' do
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
FactoryBot.define do
|
||||
factory :sla_event do
|
||||
applied_sla
|
||||
conversation
|
||||
event_type { 'frt' }
|
||||
account { conversation.account }
|
||||
inbox { conversation.inbox }
|
||||
sla_policy { applied_sla.sla_policy }
|
||||
end
|
||||
end
|
||||
@@ -22,13 +22,11 @@ RSpec.describe Contact do
|
||||
it 'sets email to lowercase' do
|
||||
contact = create(:contact, email: 'Test@test.com')
|
||||
expect(contact.email).to eq('test@test.com')
|
||||
expect(contact.contact_type).to eq('lead')
|
||||
end
|
||||
|
||||
it 'sets email to nil when empty string' do
|
||||
contact = create(:contact, email: '')
|
||||
expect(contact.email).to be_nil
|
||||
expect(contact.contact_type).to eq('visitor')
|
||||
end
|
||||
|
||||
it 'sets custom_attributes to {} when nil' do
|
||||
@@ -85,21 +83,4 @@ RSpec.describe Contact do
|
||||
expect(contact.country_code).to eq 'US'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact is created' do
|
||||
it 'has contact type "visitor" by default' do
|
||||
contact = create(:contact)
|
||||
expect(contact.contact_type).to eq 'visitor'
|
||||
end
|
||||
|
||||
it 'has contact type "lead" when email is present' do
|
||||
contact = create(:contact, email: 'test@test.com')
|
||||
expect(contact.contact_type).to eq 'lead'
|
||||
end
|
||||
|
||||
it 'has contact type "lead" when contacted through a social channel' do
|
||||
contact = create(:contact, additional_attributes: { social_facebook_user_id: '123' })
|
||||
expect(contact.contact_type).to eq 'lead'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,9 +7,9 @@ describe Contacts::FilterService do
|
||||
let!(:first_user) { create(:user, account: account) }
|
||||
let!(:second_user) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let!(:en_contact) { create(:contact, account: account, additional_attributes: { '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' }) }
|
||||
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' }) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_user, inbox: inbox)
|
||||
@@ -37,8 +37,6 @@ 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')
|
||||
@@ -48,7 +46,90 @@ describe Contacts::FilterService do
|
||||
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
|
||||
end
|
||||
|
||||
context 'with standard attributes - name' do
|
||||
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
|
||||
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
@@ -64,168 +145,7 @@ 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] = [
|
||||
{
|
||||
@@ -261,9 +181,9 @@ describe Contacts::FilterService do
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
attribute_key: 'country_code',
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: ['GR'],
|
||||
values: ['el'],
|
||||
query_operator: 'AND'
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
@@ -300,6 +220,62 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user