diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 07b0e7345..54f478920 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -16,7 +16,6 @@ class AgentBuilder def perform ActiveRecord::Base.transaction do @user = find_or_create_user - send_confirmation_if_required create_account_user end @user @@ -34,11 +33,6 @@ class AgentBuilder User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) end - # Sends confirmation instructions if the user is persisted and not confirmed. - def send_confirmation_if_required - @user.send_confirmation_instructions if user_needs_confirmation? - end - # Checks if the user needs confirmation. # @return [Boolean] true if the user is persisted and not confirmed, false otherwise. def user_needs_confirmation? diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 71e9100e7..729db34b5 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -65,6 +65,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = result[:contacts] @contacts_count = result[:count] @contacts = fetch_contacts(contacts) + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidValue => e + render_could_not_create_error(e.message) end def contactable_inboxes diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index d0d8f6d5b..2aedf1928 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -44,6 +44,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro result = ::Conversations::FilterService.new(params.permit!, current_user).perform @conversations = result[:conversations] @conversations_count = result[:count] + rescue CustomExceptions::CustomFilter::InvalidAttribute, + CustomExceptions::CustomFilter::InvalidOperator, + CustomExceptions::CustomFilter::InvalidValue => e + render_could_not_create_error(e.message) end def mute diff --git a/app/controllers/concerns/domain_helper.rb b/app/controllers/concerns/domain_helper.rb new file mode 100644 index 000000000..1b7d8f187 --- /dev/null +++ b/app/controllers/concerns/domain_helper.rb @@ -0,0 +1,5 @@ +module DomainHelper + def self.chatwoot_domain?(domain = request.host) + [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain) + end +end diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index 744a70da9..3013ff3cc 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -6,6 +6,7 @@ module SwitchLocale def switch_locale(&) # priority is for locale set in query string (mostly for widget/from js sdk) locale ||= locale_from_params + locale ||= locale_from_custom_domain # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= locale_from_env_variable set_locale(locale, &) @@ -16,6 +17,20 @@ module SwitchLocale set_locale(locale, &) end + # If the request is coming from a custom domain, it should be for a helpcenter portal + # We will use the portal locale in such cases + def locale_from_custom_domain(&) + return if params[:locale] + + domain = request.host + return if DomainHelper.chatwoot_domain?(domain) + + @portal = Portal.find_by(custom_domain: domain) + return unless @portal + + @portal.default_locale + end + def set_locale(locale, &) # if locale is empty, use default_locale locale ||= I18n.default_locale diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0aea9df83..047fd10c3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -18,6 +18,7 @@ class DashboardController < ActionController::Base 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', 'INSTALLATION_NAME', 'WIDGET_BRAND_URL', 'TERMS_URL', + 'BRAND_URL', 'BRAND_NAME', 'PRIVACY_URL', 'DISPLAY_MANIFEST', 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 4d3cc56b8..f6c10f7c4 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -47,7 +47,7 @@ class Public::Api::V1::Portals::BaseController < PublicController @locale = if article.category.present? article.category.locale else - 'en' + article.portal.default_locale end I18n.with_locale(@locale, &) diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 0c3f52ff6..3b83a2210 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -8,8 +8,7 @@ class PublicController < ActionController::Base def ensure_custom_domain_request domain = request.host - - return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain) + return if DomainHelper.chatwoot_domain?(domain) @portal = ::Portal.find_by(custom_domain: domain) return if @portal.present? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b52b2300e..76d0bacc8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,4 +2,11 @@ module ApplicationHelper def available_locales_with_name LANGUAGES_CONFIG.map { |_key, val| val.slice(:name, :iso_639_1_code) } end + + def feature_help_urls + features = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze + features.each_with_object({}) do |feature, hash| + hash[feature['name']] = feature['help_url'] if feature['help_url'] + end + end end diff --git a/app/helpers/filter_helper.rb b/app/helpers/filter_helper.rb new file mode 100644 index 000000000..bce2de5ea --- /dev/null +++ b/app/helpers/filter_helper.rb @@ -0,0 +1,84 @@ +module FilterHelper + def build_condition_query(model_filters, query_hash, current_index) + current_filter = model_filters[query_hash['attribute_key']] + + # Throw InvalidOperator Error if the attribute is a standard attribute + # and the operator is not allowed in the config + if current_filter.present? && current_filter['filter_operators'].exclude?(query_hash[:filter_operator]) + raise CustomExceptions::CustomFilter::InvalidOperator.new( + attribute_name: query_hash['attribute_key'], + allowed_keys: current_filter['filter_operators'] + ) + end + + # Every other filter expects a value to be present + if %w[is_present is_not_present].exclude?(query_hash[:filter_operator]) && query_hash['values'].blank? + raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: query_hash['attribute_key']) + end + + condition_query = build_condition_query_string(current_filter, query_hash, current_index) + # The query becomes empty only when it doesn't match to any supported + # standard attribute or custom attribute defined in the account. + if condition_query.empty? + raise CustomExceptions::CustomFilter::InvalidAttribute.new(key: query_hash['attribute_key'], + allowed_keys: model_filters.keys) + end + + condition_query + end + + def build_condition_query_string(current_filter, query_hash, current_index) + filter_operator_value = filter_operation(query_hash, current_index) + + return handle_nil_filter(query_hash, current_index) if current_filter.nil? + + case current_filter['attribute_type'] + when 'additional_attributes' + handle_additional_attributes(query_hash, filter_operator_value, current_filter['data_type']) + else + handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value) + end + end + + def handle_nil_filter(query_hash, current_index) + attribute_type = "#{filter_config[:entity].downcase}_attribute" + custom_attribute_query(query_hash, attribute_type, current_index) + end + + def handle_additional_attributes(query_hash, filter_operator_value, data_type) + if data_type == 'text_case_insensitive' + "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ + "#{filter_operator_value} #{query_hash[:query_operator]}" + else + "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ + "#{filter_operator_value} #{query_hash[:query_operator]} " + end + end + + def handle_standard_attributes(current_filter, query_hash, current_index, filter_operator_value) + case current_filter['data_type'] + when 'date' + date_filter(current_filter, query_hash, filter_operator_value) + when 'labels' + tag_filter_query(query_hash, current_index) + when 'text_case_insensitive' + text_case_insensitive_filter(query_hash, filter_operator_value) + else + default_filter(query_hash, filter_operator_value) + end + end + + def date_filter(current_filter, query_hash, filter_operator_value) + "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ + "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" + end + + def text_case_insensitive_filter(query_hash, filter_operator_value) + "LOWER(#{filter_config[:table_name]}.#{query_hash[:attribute_key]}) " \ + "#{filter_operator_value} #{query_hash[:query_operator]}" + end + + def default_filter(query_hash, filter_operator_value) + "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" + end +end diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index ea40c1f3a..54a03c403 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -1,11 +1,10 @@ // scss-lint:disable SpaceAfterPropertyColon -// @import 'shared/assets/fonts/inter'; - +@import 'shared/assets/fonts/inter'; +// Inter, html, body { font-family: 'PlusJakarta', - Inter, -apple-system, system-ui, BlinkMacSystemFont, diff --git a/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue b/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue new file mode 100644 index 000000000..381ca53b1 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/components/SLACardLabel.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index f2fd3e757..a0453b7d3 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -20,4 +20,14 @@ export const FEATURE_FLAGS = { INBOX_VIEW: 'inbox_view', SLA: 'sla', RESPONSE_BOT: 'response_bot', + CHANNEL_EMAIL: 'channel_email', + CHANNEL_FACEBOOK: 'channel_facebook', + CHANNEL_TWITTER: 'channel_twitter', + CHANNEL_WEBSITE: 'channel_website', + CUSTOM_REPLY_DOMAIN: 'custom_reply_domain', + CUSTOM_REPLY_EMAIL: 'custom_reply_email', + DISABLE_BRANDING: 'disable_branding', + EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel', + INBOUND_EMAILS: 'inbound_emails', + IP_LOOKUP: 'ip_lookup', }; diff --git a/app/javascript/dashboard/helper/directives/resize.js b/app/javascript/dashboard/helper/directives/resize.js new file mode 100644 index 000000000..35e5315b0 --- /dev/null +++ b/app/javascript/dashboard/helper/directives/resize.js @@ -0,0 +1,41 @@ +import { debounce } from '@chatwoot/utils'; + +const RESIZE_OBSERVER_DEBOUNCE_TIME = 100; + +function createResizeObserver(el, binding) { + const { value } = binding; + const observer = new ResizeObserver( + debounce(entries => { + const entry = entries[0]; + if (entry && value && typeof value === 'function') { + value(entry); + } + }, RESIZE_OBSERVER_DEBOUNCE_TIME) + ); + + el.cwResizeObserver = observer; + observer.observe(el); +} + +function destroyResizeObserver(el) { + if (el.cwResizeObserver) { + el.cwResizeObserver.unobserve(el); + el.cwResizeObserver.disconnect(); + delete el.cwResizeObserver; + } +} + +export default { + bind(el, binding) { + createResizeObserver(el, binding); + }, + update(el, binding) { + if (binding.oldValue !== binding.value) { + destroyResizeObserver(el); + createResizeObserver(el, binding); + } + }, + unbind(el) { + destroyResizeObserver(el); + }, +}; diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js new file mode 100644 index 000000000..edbbf4472 --- /dev/null +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -0,0 +1,4 @@ +export function getHelpUrlForFeature(featureName) { + const { helpUrls } = window.chatwootConfig; + return helpUrls[featureName]; +} diff --git a/app/javascript/dashboard/helper/specs/directives/resize.spec.js b/app/javascript/dashboard/helper/specs/directives/resize.spec.js new file mode 100644 index 000000000..fa099f40a --- /dev/null +++ b/app/javascript/dashboard/helper/specs/directives/resize.spec.js @@ -0,0 +1,78 @@ +import resize from '../../directives/resize'; + +class ResizeObserverMock { + // eslint-disable-next-line class-methods-use-this + observe() {} + + // eslint-disable-next-line class-methods-use-this + unobserve() {} + + // eslint-disable-next-line class-methods-use-this + disconnect() {} +} + +describe('resize directive', () => { + let el; + let binding; + let observer; + + beforeEach(() => { + el = document.createElement('div'); + binding = { + value: jest.fn(), + }; + observer = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + window.ResizeObserver = ResizeObserverMock; + jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create ResizeObserver on bind', () => { + resize.bind(el, binding); + + expect(ResizeObserver).toHaveBeenCalled(); + expect(observer.observe).toHaveBeenCalledWith(el); + }); + + it('should call callback on observer callback', () => { + el = document.createElement('div'); + binding = { + value: jest.fn(), + }; + + resize.bind(el, binding); + + const entries = [{ contentRect: { width: 100, height: 100 } }]; + const callback = binding.value; + callback(entries[0]); + + expect(binding.value).toHaveBeenCalledWith(entries[0]); + }); + + it('should destroy and recreate observer on update', () => { + resize.bind(el, binding); + + resize.update(el, { ...binding, oldValue: 'old' }); + + expect(observer.unobserve).toHaveBeenCalledWith(el); + expect(observer.disconnect).toHaveBeenCalled(); + expect(ResizeObserver).toHaveBeenCalledTimes(2); + expect(observer.observe).toHaveBeenCalledTimes(2); + }); + + it('should destroy observer on unbind', () => { + resize.bind(el, binding); + + resize.unbind(el); + + expect(observer.unobserve).toHaveBeenCalledWith(el); + expect(observer.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/contactFilters.json b/app/javascript/dashboard/i18n/locale/en/contactFilters.json index 09a543984..02d5dcf89 100644 --- a/app/javascript/dashboard/i18n/locale/en/contactFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/contactFilters.json @@ -44,7 +44,8 @@ "CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox", "CREATED_AT": "Created At", "LAST_ACTIVITY": "Last Activity", - "REFERER_LINK": "Referrer link" + "REFERER_LINK": "Referrer link", + "BLOCKED": "Blocked" }, "GROUPS": { "STANDARD_FILTERS": "Standard Filters", diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 227c802d6..2bdf2af7a 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -64,7 +64,14 @@ "SNOOZED_UNTIL": "Snoozed until", "SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow", "SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week", - "SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply" + "SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply", + "SLA_STATUS": { + "FRT": "FRT {status}", + "NRT": "NRT {status}", + "RT": "RT {status}", + "BREACH": "breach", + "DUE": "due" + } }, "RESOLVE_DROPDOWN": { "MARK_PENDING": "Mark as pending", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 9a4bde2c8..d9834c545 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -83,7 +83,10 @@ "CONVERSATION_CREATION": "Send email notifications when a new conversation is created", "CONVERSATION_MENTION": "Send email notifications when you are mentioned in a conversation", "ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation", - "PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation" + "PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation", + "SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA", + "SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA", + "SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA" }, "API": { "UPDATE_SUCCESS": "Your notification preferences are updated successfully", @@ -98,7 +101,10 @@ "ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation", "PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation", "HAS_ENABLED_PUSH": "You have enabled push for this browser.", - "REQUEST_PUSH": "Enable push notifications" + "REQUEST_PUSH": "Enable push notifications", + "SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA", + "SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA", + "SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA" }, "PROFILE_IMAGE": { "LABEL": "Profile Image" diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json index 228f87066..d9670eb06 100644 --- a/app/javascript/dashboard/i18n/locale/en/sla.json +++ b/app/javascript/dashboard/i18n/locale/en/sla.json @@ -1,15 +1,24 @@ { "SLA": { "HEADER": "SLA", - "HEADER_BTN_TXT": "Add SLA", + "ADD_ACTION": "Add SLA", + "DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.", + "LEARN_MORE": "Learn more about SLA", "LOADING": "Fetching SLAs", - "SEARCH_404": "There are no items matching this query", - "SIDEBAR_TXT": "

SLA

Think of Service Level Agreements (SLAs) like friendly promises between a service provider and a customer.

These promises set clear expectations for things like how quickly the team will respond to issues, making sure you always get a reliable and top-notch experience!

", "LIST": { "404": "There are no SLAs available in this account.", - "TITLE": "Manage SLA", - "DESC": "SLAs: Friendly promises for great service!", - "TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"] + "BUSINESS_HOURS_ON": "Business hours on", + "BUSINESS_HOURS_OFF": "Business hours off", + "RESPONSE_TYPES": { + "FRT": "First response time threshold", + "NRT": "Next response time threshold", + "RT": "Resolution time threshold", + "SHORT_HAND": { + "FRT": "FRT", + "NRT": "NRT", + "RT": "RT" + } + } }, "FORM": { "NAME": { diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue index 41ceb25e5..6f5e92017 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue @@ -243,7 +243,7 @@ export default { attr.attribute_display_type === 'checkbox' ); }); - if (isCustomAttributeCheckbox) { + if (isCustomAttributeCheckbox || type === 'blocked') { return [ { id: true, diff --git a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js index 2d54c37bc..59376f8ef 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js +++ b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js @@ -76,6 +76,14 @@ const filterTypes = [ filterOperators: OPERATOR_TYPES_5, attributeModel: 'standard', }, + { + attributeKey: 'blocked', + attributeI18nKey: 'BLOCKED', + inputType: 'search_select', + dataType: 'text', + filterOperators: OPERATOR_TYPES_1, + attributeModel: 'standard', + }, ]; export const filterAttributeGroups = [ @@ -115,6 +123,10 @@ export const filterAttributeGroups = [ key: 'last_activity_at', i18nKey: 'LAST_ACTIVITY', }, + { + key: 'blocked', + i18nKey: 'BLOCKED', + }, ], }, ]; diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue new file mode 100644 index 000000000..618805309 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -0,0 +1,23 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue new file mode 100644 index 000000000..3f5bf16f9 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsWrapper.vue @@ -0,0 +1,21 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue new file mode 100644 index 000000000..e70358cc9 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue new file mode 100644 index 000000000..337c370cd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsListItem.vue @@ -0,0 +1,53 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue index 7214d0deb..cdbc40ec4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/NotificationSettings.vue @@ -236,6 +236,54 @@ }} +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
@@ -367,6 +466,7 @@ import { requestPushPermissions, verifyServiceWorkerExistence, } from '../../../../helper/pushHelper'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; export default { mixins: [alertMixin, configMixin, uiSettingsMixin], @@ -393,13 +493,18 @@ export default { }, computed: { ...mapGetters({ + accountId: 'getCurrentAccountId', emailFlags: 'userNotificationSettings/getSelectedEmailFlags', pushFlags: 'userNotificationSettings/getSelectedPushFlags', uiSettings: 'getUISettings', + isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', }), hasPushAPISupport() { return !!('Notification' in window); }, + isSLAEnabled() { + return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA); + }, }, watch: { emailFlags(value) { diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap index 6f2a05bf5..d1e3b8b53 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/specs/__snapshots__/CSATMetrics.spec.js.snap @@ -2,9 +2,9 @@ exports[`CsatMetrics.vue computes response count correctly 1`] = `
- - - + + +
`; diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue index 8728a236c..278927d33 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue @@ -1,99 +1,58 @@ + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue new file mode 100644 index 000000000..8b3236836 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAHeader.vue @@ -0,0 +1,23 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue new file mode 100644 index 000000000..08e66dbfe --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItem.vue @@ -0,0 +1,68 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItemLoading.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItemLoading.vue new file mode 100644 index 000000000..7521ef9f5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAListItemLoading.vue @@ -0,0 +1,30 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue new file mode 100644 index 000000000..a2caaa4e5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/components/SLAResponseTime.vue @@ -0,0 +1,36 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js b/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js index 473cb03cb..a9c68470a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js @@ -1,18 +1,14 @@ import { frontendURL } from '../../../../helper/URLHelper'; -const SettingsContent = () => import('../Wrapper.vue'); +const SettingsWrapper = () => import('../SettingsWrapper.vue'); const Index = () => import('./Index.vue'); export default { routes: [ { path: frontendURL('accounts/:accountId/settings/sla'), - component: SettingsContent, - props: { - headerTitle: 'SLA.HEADER', - icon: 'document-list-clock', - showNewButton: true, - }, + component: SettingsWrapper, + props: {}, children: [ { path: '', diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 18354bc62..61d6dec8d 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -30,6 +30,7 @@ import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; import VueDOMPurifyHTML from 'vue-dompurify-html'; import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer'; import AnalyticsPlugin from '../dashboard/helper/AnalyticsHelper/plugin'; +import resizeDirective from '../dashboard/helper/directives/resize.js'; Vue.config.env = process.env; @@ -78,6 +79,7 @@ Vue.component('woot-switch', WootSwitch); Vue.component('woot-wizard', WootWizard); Vue.component('fluent-icon', FluentIcon); +Vue.directive('resize', resizeDirective); const i18nConfig = new VueI18n({ locale: 'en', messages: i18n, diff --git a/app/javascript/shared/components/EmojiOrIcon.vue b/app/javascript/shared/components/EmojiOrIcon.vue index 5cde463fe..2a93dc06f 100644 --- a/app/javascript/shared/components/EmojiOrIcon.vue +++ b/app/javascript/shared/components/EmojiOrIcon.vue @@ -4,6 +4,7 @@ v-else-if="showIcon" :size="iconSize" :icon="icon" + class="flex-shrink-0" :class="className" /> diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index e1235bf51..8c1ba6069 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -2,6 +2,8 @@ "add-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM12 7a.75.75 0 0 1 .75.75v3.5h3.5a.75.75 0 0 1 0 1.5h-3.5v3.5a.75.75 0 0 1-1.5 0v-3.5h-3.5a.75.75 0 0 1 0-1.5h3.5v-3.5A.75.75 0 0 1 12 7Z", "add-outline": "M11.75 3a.75.75 0 0 1 .743.648l.007.102.001 7.25h7.253a.75.75 0 0 1 .102 1.493l-.102.007h-7.253l.002 7.25a.75.75 0 0 1-1.493.101l-.007-.102-.002-7.249H3.752a.75.75 0 0 1-.102-1.493L3.752 11h7.25L11 3.75a.75.75 0 0 1 .75-.75Z", "add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z", + "alarm-on-outline": "m10.95 13.7l-1.425-1.425q-.3-.3-.7-.3t-.7.3q-.3.3-.3.713t.3.712l2.125 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.3-.712t-.3-.713q-.3-.3-.713-.3t-.712.3L10.95 13.7ZM12 22q-1.875 0-3.512-.713t-2.85-1.924q-1.213-1.213-1.925-2.85T3 13q0-1.875.713-3.513t1.924-2.85q1.213-1.212 2.85-1.924T12 4q1.875 0 3.513.713t2.85 1.925q1.212 1.212 1.925 2.85T21 13q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 22Zm0-9ZM2.05 7.3q-.275-.275-.275-.7t.275-.7L4.9 3.05q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7L3.45 7.3q-.275.275-.7.275t-.7-.275Zm19.9 0q-.275.275-.7.275t-.7-.275L17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7ZM12 20q2.925 0 4.963-2.038T19 13q0-2.925-2.038-4.963T12 6Q9.075 6 7.037 8.038T5 13q0 2.925 2.038 4.963T12 20Z", + "alarm-off-outline": "m19.95 17.25l-1.5-1.5q.275-.675.413-1.313T19 13.1q0-2.9-2.05-5T12 6q-.7 0-1.35.113t-1.3.387L7.85 5q.95-.5 1.988-.75T12 4q1.85 0 3.488.7t2.862 1.938q1.225 1.237 1.938 2.887T21 13.1q0 1.125-.275 2.163t-.775 1.987ZM17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275L17.7 4.45ZM12 22q-1.85 0-3.488-.7T5.65 19.4q-1.225-1.2-1.938-2.825T3 13.1q0-1.55.463-2.912T4.8 7.7l-.85-.85l-.5.5q-.275.275-.7.275t-.7-.275q-.275-.275-.275-.7t.275-.7l.5-.5L1.4 4.3q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l18.4 18.4q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.45-2.45q-1.125.825-2.487 1.288T12 22Zm0-1.975q1.05 0 2.05-.325t1.85-.9L6.2 9.15q-.575.875-.887 1.888T5 13.1q0 2.9 2.05 4.913T12 20.025Zm-.95-6.05Zm2.85-2.85Z", "alert-outline": "M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.251 1.251 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5ZM13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145l.006-.147ZM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.004-.225A5.988 5.988 0 0 0 12 3.496Z", "archive-outline": "M19.25 3c.966 0 1.75.784 1.75 1.75v2c0 .698-.408 1.3-1 1.581v9.919A3.75 3.75 0 0 1 16.25 22h-8.5A3.75 3.75 0 0 1 4 18.25V8.332A1.75 1.75 0 0 1 3 6.75v-2C3 3.784 3.784 3 4.75 3h14.5Zm-.75 5.5h-13v9.75a2.25 2.25 0 0 0 2.25 2.25h8.5a2.25 2.25 0 0 0 2.25-2.25V8.5Zm-8.5 3h4a.75.75 0 0 1 .102 1.493L14 13h-4a.75.75 0 0 1-.102-1.493L10 11.5h4-4Zm9.25-7H4.75a.25.25 0 0 0-.25.25v2c0 .138.112.25.25.25h14.5a.25.25 0 0 0 .25-.25v-2a.25.25 0 0 0-.25-.25Z", "arrow-chevron-left-outline": "M15 17.898c0 1.074-1.265 1.648-2.073.941l-6.31-5.522a1.75 1.75 0 0 1 0-2.634l6.31-5.522c.808-.707 2.073-.133 2.073.941v11.796Z", @@ -9,6 +11,7 @@ "arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z", "arrow-download-outline": "M18.25 20.5a.75.75 0 1 1 0 1.5l-13 .004a.75.75 0 1 1 0-1.5l13-.004ZM11.648 2.012l.102-.007a.75.75 0 0 1 .743.648l.007.102-.001 13.685 3.722-3.72a.75.75 0 0 1 .976-.073l.085.073a.75.75 0 0 1 .072.976l-.073.084-4.997 4.997a.75.75 0 0 1-.976.073l-.085-.073-5.003-4.996a.75.75 0 0 1 .976-1.134l.084.072 3.719 3.714L11 2.755a.75.75 0 0 1 .648-.743l.102-.007-.102.007Z", "arrow-expand-outline": "M7.669 14.923a1 1 0 0 1 1.414 1.414l-2.668 2.667H8a1 1 0 0 1 .993.884l.007.116a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v1.587l2.669-2.668Zm8.336 6.081a1 1 0 1 1 0-2h1.583l-2.665-2.667a1 1 0 0 1-.083-1.32l.083-.094a1 1 0 0 1 1.414 0l2.668 2.67v-1.589a1 1 0 0 1 .883-.993l.117-.007a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4ZM8 3a1 1 0 0 1 0 2H6.417l2.665 2.668a1 1 0 0 1 .083 1.32l-.083.094a1 1 0 0 1-1.414 0L5 6.412V8a1 1 0 0 1-.883.993L4 9a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4Zm12.005 0a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V6.412l-2.668 2.67a1 1 0 0 1-1.32.083l-.094-.083a1 1 0 0 1 0-1.414L17.589 5h-1.584a1 1 0 0 1-.993-.883L15.005 4a1 1 0 0 1 1-1h4Z", + "arrow-outwards-outline": "m16 8.4l-8.875 8.9q-.3.3-.713.3t-.712-.3q-.3-.3-.3-.713t.3-.712L14.6 7H7q-.425 0-.713-.288T6 6q0-.425.288-.713T7 5h10q.425 0 .713.288T18 6v10q0 .425-.288.713T17 17q-.425 0-.713-.288T16 16V8.4Z", "arrow-redo-outline": "M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z", "arrow-right-import-outline": "M21.25 4.5a.75.75 0 0 1 .743.648L22 5.25v13.004a.75.75 0 0 1-1.493.102l-.007-.102V5.25a.75.75 0 0 1 .75-.75Zm-8.603 1.804l.072-.084a.75.75 0 0 1 .977-.073l.084.073l4.997 4.997a.75.75 0 0 1 .073.976l-.073.085l-4.997 5.003a.75.75 0 0 1-1.133-.976l.072-.084l3.711-3.717H2.75a.75.75 0 0 1-.743-.647L2 11.755a.75.75 0 0 1 .648-.743l.102-.007l13.693-.001l-3.724-3.724a.75.75 0 0 1-.072-.976l.072-.084l-.072.084Z", "arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z", @@ -118,6 +121,7 @@ "headphones-sound-wave-outline": "M3.5 12a8.5 8.5 0 0 1 17 0v2h-2.25a.75.75 0 0 0-.75.75v6.5c0 .414.336.75.75.75H19a3 3 0 0 0 3-3v-7c0-5.523-4.477-10-10-10S2 6.477 2 12v7a3 3 0 0 0 3 3h.75a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75H3.5v-2Zm17 3.5V19a1.5 1.5 0 0 1-1.5 1.5v-5h1.5ZM3.5 19v-3.5H5v5A1.5 1.5 0 0 1 3.5 19Zm9.25-7.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5Zm-4 2.25a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5a.75.75 0 0 1 .75-.75Zm7.25.75a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0v-4.5Z", "image-outline": "M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm.58 16.401-5.805-5.686a.75.75 0 0 0-.966-.071l-.084.07-5.807 5.687c.182.064.378.099.582.099h11.5c.203 0 .399-.035.58-.099l-5.805-5.686L18.33 19.4ZM17.75 4.5H6.25A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .208.036.408.103.594l5.823-5.701a2.25 2.25 0 0 1 3.02-.116l.128.116 5.822 5.702c.067-.186.104-.386.104-.595V6.25a1.75 1.75 0 0 0-1.75-1.75Zm-2.498 2a2.252 2.252 0 1 1 0 4.504 2.252 2.252 0 0 1 0-4.504Zm0 1.5a.752.752 0 1 0 0 1.504.752.752 0 0 0 0-1.504Z", "info-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999Zm0 1.5a8.502 8.502 0 1 0 0 17.003A8.502 8.502 0 0 0 12 3.5Zm-.004 7a.75.75 0 0 1 .744.648l.007.102.003 5.502a.75.75 0 0 1-1.493.102l-.007-.101-.003-5.502a.75.75 0 0 1 .75-.75ZM12 7.003a.999.999 0 1 1 0 1.997.999.999 0 0 1 0-1.997Z", + "information-outline": "M12 17q.425 0 .713-.288T13 16v-4q0-.425-.288-.713T12 11q-.425 0-.713.288T11 12v4q0 .425.288.713T12 17Zm0-8q.425 0 .713-.288T13 8q0-.425-.288-.713T12 7q-.425 0-.713.288T11 8q0 .425.288.713T12 9Zm0 13q-2.075 0-3.9-.788t-3.175-2.137q-1.35-1.35-2.137-3.175T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12q0 2.075-.788 3.9t-2.137 3.175q-1.35 1.35-3.175 2.138T12 22Zm0-2q3.35 0 5.675-2.325T20 12q0-3.35-2.325-5.675T12 4Q8.65 4 6.325 6.325T4 12q0 3.35 2.325 5.675T12 20Zm0-8Z", "key-outline": "M15 6a1 1 0 1 1-2 0a1 1 0 0 1 2 0Zm-2.5-4C9.424 2 7 4.424 7 7.5c0 .397.04.796.122 1.175c.058.27-.008.504-.142.638l-4.54 4.54A1.5 1.5 0 0 0 2 14.915V16.5A1.5 1.5 0 0 0 3.5 18h2A1.5 1.5 0 0 0 7 16.5V16h1a1 1 0 0 0 1-1v-1h1a1 1 0 0 0 1-1v-.18c.493.134 1.007.18 1.5.18c3.076 0 5.5-2.424 5.5-5.5S15.576 2 12.5 2ZM8 7.5C8 4.976 9.976 3 12.5 3S17 4.976 17 7.5S15.024 12 12.5 12c-.66 0-1.273-.095-1.776-.347A.5.5 0 0 0 10 12.1v.9H9a1 1 0 0 0-1 1v1H7a1 1 0 0 0-1 1v.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-1.586a.5.5 0 0 1 .146-.353l4.541-4.541c.432-.432.522-1.044.412-1.556A4.619 4.619 0 0 1 8 7.5Z", "keyboard-outline": "M19.745 5a2.25 2.25 0 0 1 2.25 2.25v9.505a2.25 2.25 0 0 1-2.25 2.25H4.25A2.25 2.25 0 0 1 2 16.755V7.25A2.25 2.25 0 0 1 4.25 5h15.495Zm0 1.5H4.25a.75.75 0 0 0-.75.75v9.505c0 .414.336.75.75.75h15.495a.75.75 0 0 0 .75-.75V7.25a.75.75 0 0 0-.75-.75Zm-12.995 8h10.5a.75.75 0 0 1 .102 1.493L17.25 16H6.75a.75.75 0 0 1-.102-1.493l.102-.007h10.5-10.5ZM16.5 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-5.995 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm6 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM6 8a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm2.995 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z", "library-outline": "M4 3h1c1.054 0 1.918.816 1.995 1.85L7 5v14a2.001 2.001 0 0 1-1.85 1.994L5 21H4a2.001 2.001 0 0 1-1.995-1.85L2 19V5c0-1.054.816-1.918 1.85-1.995L4 3h1-1Zm6 0h1c1.054 0 1.918.816 1.995 1.85L13 5v14a2.001 2.001 0 0 1-1.85 1.994L11 21h-1a2.001 2.001 0 0 1-1.995-1.85L8 19V5c0-1.054.816-1.918 1.85-1.995L10 3h1-1Zm6.974 2c.84 0 1.608.531 1.89 1.346l.047.157 3.015 11.745a2 2 0 0 1-1.296 2.392l-.144.043-.969.248a2.002 2.002 0 0 1-2.387-1.284l-.047-.155-3.016-11.745a2 2 0 0 1 1.298-2.392l.143-.043.968-.248c.166-.043.334-.064.498-.064ZM5 4.5H4a.501.501 0 0 0-.492.41L3.5 5v14c0 .244.177.45.41.492L4 19.5h1c.245 0 .45-.178.492-.41L5.5 19V5a.501.501 0 0 0-.41-.492L5 4.5Zm6 0h-1a.501.501 0 0 0-.492.41L9.5 5v14c0 .244.177.45.41.492l.09.008h1c.245 0 .45-.178.492-.41L11.5 19V5a.501.501 0 0 0-.41-.492L11 4.5Zm5.975 2-.063.004-.063.013-.968.247a.498.498 0 0 0-.376.51l.015.1 3.016 11.745a.5.5 0 0 0 .483.375l.063-.003.062-.012.97-.25a.5.5 0 0 0 .374-.519l-.015-.088-3.015-11.747a.501.501 0 0 0-.483-.375Z", @@ -166,6 +170,7 @@ "person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z", "person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z", "play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z", + "plus-sign-outline": "M12 19q-.425 0-.713-.288T11 18v-5H6q-.425 0-.713-.288T5 12q0-.425.288-.713T6 11h5V6q0-.425.288-.713T12 5q.425 0 .713.288T13 6v5h5q.425 0 .713.288T19 12q0 .425-.288.713T18 13h-5v5q0 .425-.288.713T12 19Z", "power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z", "quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z", "repeat-outline": "m14.712 2.289l-.087-.078a1 1 0 0 0-1.327.078l-.078.087a.999.999 0 0 0 .078 1.326l1.299 1.297H8.999l-.24.004A6.997 6.997 0 0 0 2 11.993a6.94 6.94 0 0 0 1.189 3.899a.999.999 0 0 0 1.626-1.163l-.135-.218A4.997 4.997 0 0 1 9 6.998h5.595l-1.297 1.297l-.078.087a.999.999 0 0 0 1.492 1.326l3.006-3.003l.077-.087a.999.999 0 0 0-.078-1.326l-3.005-3.003Zm6.075 5.771A.999.999 0 0 0 19 8.677c0 .209.064.402.172.561a4.997 4.997 0 0 1-4.17 7.75H9.414l1.294-1.29l.083-.096a1 1 0 0 0-.006-1.23l-.077-.088l-.095-.084a1.001 1.001 0 0 0-1.232.006l-.088.078l-3.005 3.003l-.083.095a1 1 0 0 0 .006 1.231l.077.087l3.005 3.003l.095.084a1 1 0 0 0 1.397-1.41l-.077-.087l-1.304-1.303H15l.24-.003a6.997 6.997 0 0 0 5.546-10.927v.003Z", diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index 5722e76a7..c528b9d6a 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -12,10 +12,10 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob process_email_for_channel(channel) end rescue *ExceptionList::IMAP_EXCEPTIONS => e - Rails.logger.error e + Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" channel.authorization_error! rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e - Rails.logger.error e + Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" rescue LockAcquisitionError Rails.logger.error "Lock failed for #{channel.inbox.id}" rescue StandardError => e diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index 6c0f7cee2..dc4e6d7fe 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -69,6 +69,18 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer send_mail_with_liquid(to: email_to, subject: subject) and return end + def automation_rule_disabled(rule) + return unless smtp_config_set_or_development? + + @action_url ||= "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/automation/list" + + subject = 'Automation rule disabled due to validation errors.'.freeze + @meta = {} + @meta['rule_name'] = rule.name + + send_mail_with_liquid(to: admin_emails, subject: subject) and return + end + private def admin_emails diff --git a/app/mailers/agent_notifications/conversation_notifications_mailer.rb b/app/mailers/agent_notifications/conversation_notifications_mailer.rb index 874449adf..bc498d43b 100644 --- a/app/mailers/agent_notifications/conversation_notifications_mailer.rb +++ b/app/mailers/agent_notifications/conversation_notifications_mailer.rb @@ -61,7 +61,10 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer user: @agent, conversation: @conversation, inbox: @conversation.inbox, - message: @message + message: @message, + sla_policy: @sla_policy }) end end + +AgentNotifications::ConversationNotificationsMailer.include_mod_with('AgentNotifications::ConversationNotificationsMailer') diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 117950e35..64fc8cebf 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -5,11 +5,13 @@ class ApplicationRecord < ActiveRecord::Base before_validation :validates_column_content_length # the models that exposed in email templates through liquid - DROPPABLES = %w[Account Channel Conversation Inbox User Message].freeze + def droppables + %w[Account Channel Conversation Inbox User Message] + end # ModelDrop class should exist in app/drops def to_drop - return unless DROPPABLES.include?(self.class.name) + return unless droppables.include?(self.class.name) "#{self.class.name}Drop".constantize.new(self) end @@ -47,3 +49,5 @@ class ApplicationRecord < ActiveRecord::Base end end end + +ApplicationRecord.include_mod_with('Enterprise::ApplicationRecord') diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 955df7497..9f7da28a0 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -19,6 +19,7 @@ # class AutomationRule < ApplicationRecord include Rails.application.routes.url_helpers + include Reauthorizable belongs_to :account has_many_attached :files @@ -28,6 +29,8 @@ class AutomationRule < ApplicationRecord validate :query_operator_presence validates :account_id, presence: true + after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? } + scope :active, -> { where(active: true) } def conditions_attributes diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index fa46d0060..0fc234ada 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -50,6 +50,9 @@ module Reauthorizable mailer.whatsapp_disconnect(inbox).deliver_later when 'Channel::Email' mailer.email_disconnect(inbox).deliver_later + when 'AutomationRule' + update!(active: false) + mailer.automation_rule_disabled(self).deliver_later end end diff --git a/app/models/contact.rb b/app/models/contact.rb index a60e9f4d2..95ee69c75 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -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 diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 72518f250..513c993da 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -6,7 +6,7 @@ # additional_attributes :jsonb # agent_last_seen_at :datetime # assignee_last_seen_at :datetime -# cached_label_list :string +# cached_label_list :text # contact_last_seen_at :datetime # custom_attributes :jsonb # first_reply_created_at :datetime diff --git a/app/models/notification.rb b/app/models/notification.rb index c71bdbb62..b5834225a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -118,11 +118,11 @@ class Notification < ApplicationRecord def push_message_body case notification_type - when 'conversation_creation' + when 'conversation_creation', 'sla_missed_first_response' message_body(conversation.messages.first) when 'assigned_conversation_new_message', 'participating_conversation_new_message', 'conversation_mention' message_body(secondary_actor) - when 'conversation_assignment' + when 'conversation_assignment', 'sla_missed_next_response', 'sla_missed_resolution' message_body(conversation.messages.incoming.last) else '' diff --git a/app/models/user.rb b/app/models/user.rb index 3fdaf7f25..faadb3271 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/automation_rules/condition_validation_service.rb b/app/services/automation_rules/condition_validation_service.rb index 6f9f9a7c3..63e58b5bc 100644 --- a/app/services/automation_rules/condition_validation_service.rb +++ b/app/services/automation_rules/condition_validation_service.rb @@ -5,8 +5,8 @@ class AutomationRules::ConditionValidationService @rule = rule @account = rule.account - file = File.read('./lib/filters/filter_keys.json') - @filters = JSON.parse(file) + file = File.read('./lib/filters/filter_keys.yml') + @filters = YAML.safe_load(file) @conversation_filters = @filters['conversations'] @contact_filters = @filters['contacts'] diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb index 44b72db9e..23873371d 100644 --- a/app/services/automation_rules/conditions_filter_service.rb +++ b/app/services/automation_rules/conditions_filter_service.rb @@ -11,8 +11,9 @@ class AutomationRules::ConditionsFilterService < FilterService @account = conversation.account # setup filters from json file - file = File.read('./lib/filters/filter_keys.json') - @filters = JSON.parse(file) + file = File.read('./lib/filters/filter_keys.yml') + @filters = YAML.safe_load(file) + @conversation_filters = @filters['conversations'] @contact_filters = @filters['contacts'] @message_filters = @filters['messages'] @@ -44,8 +45,8 @@ class AutomationRules::ConditionsFilterService < FilterService def rule_valid? is_valid = AutomationRules::ConditionValidationService.new(@rule).perform - Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid + @rule.authorization_error! unless is_valid is_valid end diff --git a/app/services/contacts/filter_service.rb b/app/services/contacts/filter_service.rb index 70062cad0..d3b24e4e2 100644 --- a/app/services/contacts/filter_service.rb +++ b/app/services/contacts/filter_service.rb @@ -2,7 +2,7 @@ class Contacts::FilterService < FilterService ATTRIBUTE_MODEL = 'contact_attribute'.freeze def perform - @contacts = contact_query_builder + @contacts = query_builder(@filters['contacts']) { contacts: @contacts, @@ -10,38 +10,6 @@ class Contacts::FilterService < FilterService } end - def contact_query_builder - contact_filters = @filters['contacts'] - - @params[:payload].each_with_index do |query_hash, current_index| - current_filter = contact_filters[query_hash['attribute_key']] - @query_string += contact_query_string(current_filter, query_hash, current_index) - end - - base_relation.where(@query_string, @filter_values.with_indifferent_access) - end - - def contact_query_string(current_filter, query_hash, current_index) - attribute_key = query_hash[:attribute_key] - query_operator = query_hash[:query_operator] - filter_operator_value = filter_operation(query_hash, current_index) - - return custom_attribute_query(query_hash, 'contact_attribute', current_index) if current_filter.nil? - - case current_filter['attribute_type'] - when 'additional_attributes' - " LOWER(contacts.additional_attributes ->> '#{attribute_key}') #{filter_operator_value} #{query_operator} " - when 'date_attributes' - " (contacts.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} " - when 'standard' - if attribute_key == 'labels' - " #{tag_filter_query('Contact', 'contacts', query_hash, current_index)} " - else - " LOWER(contacts.#{attribute_key}) #{filter_operator_value} #{query_operator} " - end - end - end - def filter_values(query_hash) current_val = query_hash['values'][0] if query_hash['attribute_key'] == 'phone_number' @@ -57,6 +25,13 @@ class Contacts::FilterService < FilterService Current.account.contacts end + def filter_config + { + entity: 'Contact', + table_name: 'contacts' + } + end + private def equals_to_filter_string(filter_operator, current_index) diff --git a/app/services/conversations/filter_service.rb b/app/services/conversations/filter_service.rb index 87384017c..3a06959b8 100644 --- a/app/services/conversations/filter_service.rb +++ b/app/services/conversations/filter_service.rb @@ -7,7 +7,7 @@ class Conversations::FilterService < FilterService end def perform - @conversations = conversation_query_builder + @conversations = query_builder(@filters['conversations']) mine_count, unassigned_count, all_count, = set_count_for_all_conversations assigned_count = all_count - unassigned_count @@ -22,37 +22,6 @@ class Conversations::FilterService < FilterService } end - def conversation_query_builder - conversation_filters = @filters['conversations'] - @params[:payload].each_with_index do |query_hash, current_index| - current_filter = conversation_filters[query_hash['attribute_key']] - @query_string += conversation_query_string(current_filter, query_hash, current_index) - end - - base_relation.where(@query_string, @filter_values.with_indifferent_access) - end - - def conversation_query_string(current_filter, query_hash, current_index) - attribute_key = query_hash[:attribute_key] - query_operator = query_hash[:query_operator] - filter_operator_value = filter_operation(query_hash, current_index) - - return custom_attribute_query(query_hash, 'conversation_attribute', current_index) if current_filter.nil? - - case current_filter['attribute_type'] - when 'additional_attributes' - " conversations.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} " - when 'date_attributes' - " (conversations.#{attribute_key})::#{current_filter['data_type']} #{filter_operator_value}#{current_filter['data_type']} #{query_operator} " - when 'standard' - if attribute_key == 'labels' - " #{tag_filter_query('Conversation', 'conversations', query_hash, current_index)} " - else - " conversations.#{attribute_key} #{filter_operator_value} #{query_operator} " - end - end - end - def base_relation @account.conversations.includes( :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox @@ -63,6 +32,13 @@ class Conversations::FilterService < FilterService @params[:page] || 1 end + def filter_config + { + entity: 'Conversation', + table_name: 'conversations' + } + end + def conversations @conversations.sort_on_last_activity_at.page(current_page) end diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb index f1a699811..c545ac7d8 100644 --- a/app/services/filter_service.rb +++ b/app/services/filter_service.rb @@ -1,6 +1,9 @@ require 'json' class FilterService + include FilterHelper + include CustomExceptions::CustomFilter + ATTRIBUTE_MODEL = 'conversation_attribute'.freeze ATTRIBUTE_TYPES = { date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean' @@ -9,8 +12,8 @@ class FilterService def initialize(params, user) @params = params @user = user - file = File.read('./lib/filters/filter_keys.json') - @filters = JSON.parse(file) + file = File.read('./lib/filters/filter_keys.yml') + @filters = YAML.safe_load(file) @query_string = '' @filter_values = {} end @@ -106,7 +109,9 @@ class FilterService ] end - def tag_filter_query(model_name, table_name, query_hash, current_index) + def tag_filter_query(query_hash, current_index) + model_name = filter_config[:entity] + table_name = filter_config[:table_name] query_operator = query_hash[:query_operator] @filter_values["value_#{current_index}"] = filter_values(query_hash) @@ -130,10 +135,8 @@ class FilterService def custom_attribute_query(query_hash, custom_attribute_type, current_index) @attribute_key = query_hash[:attribute_key] @custom_attribute_type = custom_attribute_type - attribute_data_type - - return ' ' if @custom_attribute.blank? + return '' if @custom_attribute.blank? build_custom_attr_query(query_hash, current_index) end @@ -155,9 +158,9 @@ class FilterService table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' query = if attribute_data_type == 'text' - " LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " + "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " else - " (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " + "(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " end query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) @@ -194,4 +197,11 @@ class FilterService "NOT LIKE :value_#{current_index}" end + + def query_builder(model_filters) + @params[:payload].each_with_index do |query_hash, current_index| + @query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}" + end + base_relation.where(@query_string, @filter_values.with_indifferent_access) + end end diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb index 2e49a7b96..a80f23cce 100644 --- a/app/views/layouts/vueapp.html.erb +++ b/app/views/layouts/vueapp.html.erb @@ -44,6 +44,7 @@ vapidPublicKey: new Uint8Array(<%= Base64.urlsafe_decode64(@global_config['VAPID_PUBLIC_KEY']).bytes %>), <% end %> enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>, + helpUrls: <%= feature_help_urls.to_json.html_safe %>, selectedLocale: '<%= I18n.locale %>' } window.globalConfig = <%= raw @global_config.to_json %> diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid b/app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid new file mode 100644 index 000000000..56aefd6b7 --- /dev/null +++ b/app/views/mailers/administrator_notifications/channel_notifications_mailer/automation_rule_disabled.liquid @@ -0,0 +1,8 @@ +

Hello there,

+ +

The automation rule {{meta['rule_name']}} has been disabled becuase it has invalid conditions.

+

This typically happens when you delete any custom attributes which are still being used in automation rules.

+ +

+Click here to update the conditions. +

diff --git a/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid new file mode 100644 index 000000000..d7988ad5f --- /dev/null +++ b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_first_response.liquid @@ -0,0 +1,10 @@ +

Hi {{user.available_name}},

+ +

+ Conversation #{{conversation.display_id}} in {{ inbox.name }} + has missed the SLA for first response under policy {{ sla_policy.name }}. +

+ +

+Please address immediately. +

diff --git a/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid new file mode 100644 index 000000000..d7bf8d445 --- /dev/null +++ b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_next_response.liquid @@ -0,0 +1,10 @@ +

Hi {{user.available_name}},

+ +

+ Conversation #{{conversation.display_id}} in {{ inbox.name }} + has missed the SLA for next response under policy {{ sla_policy.name }}.. +

+ +

+Please address immediately. +

diff --git a/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid new file mode 100644 index 000000000..efd24913e --- /dev/null +++ b/app/views/mailers/agent_notifications/conversation_notifications_mailer/sla_missed_resolution.liquid @@ -0,0 +1,10 @@ +

Hi {{user.available_name}},

+ +

+ Conversation #{{conversation.display_id}} in {{ inbox.name }} + has missed the SLA for resolution time under policy {{ sla_policy.name }}. +

+ +

+Please address immediately. +

diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 9b875fcee..544fa1ba8 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -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| %> - + <% end %> <%= render partial: 'icons/chevron-down' %> diff --git a/config/app.yml b/config/app.yml index 99f2706e9..220fabfe7 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '3.6.0' + version: '3.7.0' development: <<: *shared diff --git a/config/features.yml b/config/features.yml index f0477c297..37439c31f 100644 --- a/config/features.yml +++ b/config/features.yml @@ -3,37 +3,45 @@ enabled: true - name: channel_email enabled: true + help_url: https://chwt.app/hc/email - name: channel_facebook enabled: true + help_url: https://chwt.app/hc/fb - name: channel_twitter enabled: true - name: ip_lookup enabled: false - name: disable_branding enabled: false - premium: true + premium: true - name: email_continuity_on_api_channel enabled: false - name: help_center enabled: true + help_url: https://chwt.app/hc/help-center - name: agent_bots enabled: false + help_url: https://chwt.app/hc/agent-bots - name: macros enabled: true - name: agent_management enabled: true - name: team_management enabled: true + help_url: https://chwt.app/hc/teams - name: inbox_management enabled: true - name: labels enabled: true + help_url: https://chwt.app/hc/labels - name: custom_attributes enabled: true + help_url: https://chwt.app/hc/custom-attributes - name: automations enabled: true - name: canned_responses enabled: true + help_url: https://chwt.app/hc/canned - name: integrations enabled: true - name: voice_recorder @@ -44,8 +52,10 @@ enabled: true - name: campaigns enabled: true + help_url: https://chwt.app/hc/campaigns - name: reports enabled: true + help_url: https://chwt.app/hc/reports - name: crm enabled: true - name: auto_resolve_conversations @@ -62,6 +72,7 @@ premium: true - name: message_reply_to enabled: false + help_url: https://chwt.app/hc/reply-to - name: insert_article_in_reply enabled: false - name: inbox_view diff --git a/config/locales/en.yml b/config/locales/en.yml index c19499fa5..92c56574d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -77,7 +77,9 @@ en: name: should not start or end with symbols, and it should not have < > / \ @ characters. custom_filters: number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50. - + invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account. + invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}]. + invalid_value: Invalid value. The values provided for %{attribute_name} are invalid reports: period: Reporting period %{since} to %{until} utc_warning: The report generated is in UTC timezone @@ -104,6 +106,15 @@ en: avg_resolution_time: Avg resolution time conversation_traffic_csv: timezone: Timezone + sla_csv: + conversation_id: Conversation ID + sla_policy_breached: SLA Policy + assignee: Assignee + team: Team + inbox: Inbox + labels: Labels + conversation_link: Link to the Conversation + breached_events: Breached Events default_group_by: day csat: headers: diff --git a/config/routes.rb b/config/routes.rb index cfa14c854..d002bd142 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -144,6 +144,12 @@ Rails.application.routes.draw do get :download end end + resources :applied_slas, only: [:index] do + collection do + get :metrics + get :download + end + end resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy] resources :custom_filters, only: [:index, :show, :create, :update, :destroy] resources :inboxes, only: [:index, :show, :create, :update, :destroy] do diff --git a/db/migrate/20240319062553_create_sla_events.rb b/db/migrate/20240319062553_create_sla_events.rb new file mode 100644 index 000000000..a6f1a5de2 --- /dev/null +++ b/db/migrate/20240319062553_create_sla_events.rb @@ -0,0 +1,16 @@ +class CreateSlaEvents < ActiveRecord::Migration[7.0] + def change + create_table :sla_events do |t| + t.references :applied_sla, null: false + t.references :conversation, null: false + t.references :account, null: false + t.references :sla_policy, null: false + t.references :inbox, null: false + + t.integer :event_type + t.jsonb :meta, default: {} + + t.timestamps + end + end +end diff --git a/db/migrate/20240322071629_convert_cached_label_list_to_text.rb b/db/migrate/20240322071629_convert_cached_label_list_to_text.rb new file mode 100644 index 000000000..91dfb428a --- /dev/null +++ b/db/migrate/20240322071629_convert_cached_label_list_to_text.rb @@ -0,0 +1,32 @@ +class ConvertCachedLabelListToText < ActiveRecord::Migration[7.0] + def up + change_column :conversations, :cached_label_list, :text + end + + def down + # This might cause data loss if the text is longer than 255 characters + # lets start by truncating the data to 255 characters + Conversation.where('LENGTH(cached_label_list) > 255').find_in_batches do |conversation_batch| + Conversation.transaction do + conversation_batch.each do |conversation| + conversation.update!(cached_label_list: truncate_list(conversation.cached_label_list)) + end + end + end + + change_column :conversations, :cached_label_list, :string + end + + private + + # Truncate the list to 255 characters or less + # by removing the last element until the length is less than 255 + def truncate_list(label_list) + labels = label_list.split(',') + + # we add the `labels.length - 1` to account for the commas + labels.pop while (labels.join(',').length + labels.length - 1) > 255 + + labels.join(',') + end +end diff --git a/db/schema.rb b/db/schema.rb index d0499cb7b..a877bf05d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do +ActiveRecord::Schema[7.0].define(version: 2024_03_22_071629) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -472,7 +472,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do t.integer "priority" t.bigint "sla_policy_id" t.datetime "waiting_since" - t.string "cached_label_list" + t.text "cached_label_list" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" @@ -842,6 +842,23 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_201954) do t.index ["user_id"], name: "index_reporting_events_on_user_id" end + create_table "sla_events", force: :cascade do |t| + t.bigint "applied_sla_id", null: false + t.bigint "conversation_id", null: false + t.bigint "account_id", null: false + t.bigint "sla_policy_id", null: false + t.bigint "inbox_id", null: false + t.integer "event_type" + t.jsonb "meta", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_sla_events_on_account_id" + t.index ["applied_sla_id"], name: "index_sla_events_on_applied_sla_id" + t.index ["conversation_id"], name: "index_sla_events_on_conversation_id" + t.index ["inbox_id"], name: "index_sla_events_on_inbox_id" + t.index ["sla_policy_id"], name: "index_sla_events_on_sla_policy_id" + end + create_table "sla_policies", force: :cascade do |t| t.string "name", null: false t.float "first_response_time_threshold" diff --git a/enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb b/enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb new file mode 100644 index 000000000..4ec27bbbb --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb @@ -0,0 +1,72 @@ +class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAccountsController + include Sift + include DateRangeHelper + + RESULTS_PER_PAGE = 25 + + before_action :set_applied_slas, only: [:index, :metrics, :download] + before_action :set_current_page, only: [:index] + before_action :paginate_slas, only: [:index] + before_action :check_admin_authorization? + + sort_on :created_at, type: :datetime + + def index; end + + def metrics + @total_applied_slas = total_applied_slas + @number_of_sla_breaches = number_of_sla_breaches + @hit_rate = hit_rate + end + + def download + @breached_slas = breached_slas + + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=breached_conversation.csv' + render layout: false, formats: [:csv] + end + + private + + def breached_slas + @applied_slas.includes(:sla_policy).joins(:conversation) + .where.not(conversations: { status: :resolved }) + .where(applied_slas: { sla_status: :missed }) + end + + def total_applied_slas + @total_applied_slas ||= @applied_slas.count + end + + def number_of_sla_breaches + @number_of_sla_breaches ||= @applied_slas.missed.count + end + + def hit_rate + number_of_sla_breaches.zero? ? '100%' : "#{hit_rate_percentage}%" + end + + def hit_rate_percentage + ((total_applied_slas - number_of_sla_breaches) / total_applied_slas.to_f * 100).round(2) + end + + def set_applied_slas + initial_query = Current.account.applied_slas.includes(:conversation) + @applied_slas = initial_query + .filter_by_date_range(range) + .filter_by_inbox_id(params[:inbox_id]) + .filter_by_team_id(params[:team_id]) + .filter_by_sla_policy_id(params[:sla_policy_id]) + .filter_by_label_list(params[:label_list]) + .filter_by_assigned_agent_id(params[:assigned_agent_id]) + end + + def paginate_slas + @applied_slas = @applied_slas.page(@current_page).per(RESULTS_PER_PAGE) + end + + def set_current_page + @current_page = params[:page] || 1 + end +end diff --git a/enterprise/app/drops/sla_policy_drop.rb b/enterprise/app/drops/sla_policy_drop.rb new file mode 100644 index 000000000..ea9fbe34d --- /dev/null +++ b/enterprise/app/drops/sla_policy_drop.rb @@ -0,0 +1,9 @@ +class SlaPolicyDrop < BaseDrop + def name + @obj.try(:name) + end + + def description + @obj.try(:description) + end +end diff --git a/enterprise/app/jobs/sla/process_account_applied_slas_job.rb b/enterprise/app/jobs/sla/process_account_applied_slas_job.rb index 153749267..d8786565c 100644 --- a/enterprise/app/jobs/sla/process_account_applied_slas_job.rb +++ b/enterprise/app/jobs/sla/process_account_applied_slas_job.rb @@ -2,7 +2,7 @@ class Sla::ProcessAccountAppliedSlasJob < ApplicationJob queue_as :medium def perform(account) - account.applied_slas.where(sla_status: 'active').each do |applied_sla| + account.applied_slas.where(sla_status: %w[active active_with_misses]).each do |applied_sla| Sla::ProcessAppliedSlaJob.perform_later(applied_sla) end end diff --git a/enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb b/enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb new file mode 100644 index 000000000..df71beb10 --- /dev/null +++ b/enterprise/app/mailers/enterprise/agent_notifications/conversation_notifications_mailer.rb @@ -0,0 +1,32 @@ +module Enterprise::AgentNotifications::ConversationNotificationsMailer + def sla_missed_first_response(conversation, agent, sla_policy) + return unless smtp_config_set_or_development? + + @agent = agent + @conversation = conversation + @sla_policy = sla_policy + subject = "Conversation [ID - #{@conversation.display_id}] missed SLA for first response" + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: subject) and return + end + + def sla_missed_next_response(conversation, agent, sla_policy) + return unless smtp_config_set_or_development? + + @agent = agent + @conversation = conversation + @sla_policy = sla_policy + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for next response") and return + end + + def sla_missed_resolution(conversation, agent, sla_policy) + return unless smtp_config_set_or_development? + + @agent = agent + @conversation = conversation + @sla_policy = sla_policy + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: "Conversation [ID - #{@conversation.display_id}] missed SLA for resolution time") and return + end +end diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index 48fd852e4..111b78e84 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -22,11 +22,24 @@ class AppliedSla < ApplicationRecord belongs_to :sla_policy belongs_to :conversation + has_many :sla_events, dependent: :destroy + validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] } before_validation :ensure_account_id - enum sla_status: { active: 0, hit: 1, missed: 2 } + enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 } + scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? } + scope :filter_by_inbox_id, ->(inbox_id) { where(inbox_id: inbox_id) if inbox_id.present? } + scope :filter_by_team_id, ->(team_id) { where(team_id: team_id) if team_id.present? } + scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? } + scope :filter_by_label_list, ->(label_list) { joins(:conversation).where(conversations: { cached_label_list: label_list }) if label_list.present? } + scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id| + if assigned_agent_id.present? + joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id }) + end + } + scope :missed, -> { where(sla_status: :missed) } private def ensure_account_id diff --git a/enterprise/app/models/enterprise/application_record.rb b/enterprise/app/models/enterprise/application_record.rb new file mode 100644 index 000000000..a05f60767 --- /dev/null +++ b/enterprise/app/models/enterprise/application_record.rb @@ -0,0 +1,5 @@ +module Enterprise::ApplicationRecord + def droppables + super + %w[SlaPolicy] + end +end diff --git a/enterprise/app/models/sla_event.rb b/enterprise/app/models/sla_event.rb new file mode 100644 index 000000000..59068684e --- /dev/null +++ b/enterprise/app/models/sla_event.rb @@ -0,0 +1,78 @@ +# == Schema Information +# +# Table name: sla_events +# +# id :bigint not null, primary key +# event_type :integer +# meta :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# applied_sla_id :bigint not null +# conversation_id :bigint not null +# inbox_id :bigint not null +# sla_policy_id :bigint not null +# +# Indexes +# +# index_sla_events_on_account_id (account_id) +# index_sla_events_on_applied_sla_id (applied_sla_id) +# index_sla_events_on_conversation_id (conversation_id) +# index_sla_events_on_inbox_id (inbox_id) +# index_sla_events_on_sla_policy_id (sla_policy_id) +# +class SlaEvent < ApplicationRecord + belongs_to :account + belongs_to :inbox + belongs_to :conversation + belongs_to :sla_policy + belongs_to :applied_sla + + enum event_type: { frt: 0, nrt: 1, rt: 2 } + + before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id + + after_create_commit :create_notifications + + private + + def ensure_applied_sla_id + self.applied_sla_id ||= AppliedSla.find_by(conversation_id: conversation_id)&.last&.id + end + + def ensure_account_id + self.account_id ||= conversation&.account_id + end + + def ensure_inbox_id + self.inbox_id ||= conversation&.inbox_id + end + + def ensure_sla_policy_id + self.sla_policy_id ||= applied_sla&.sla_policy_id + end + + def create_notifications + notify_users = conversation.conversation_participants.map(&:user) + # Add all admins from the account to notify list + notify_users += account.administrators + # Ensure conversation assignee is notified + notify_users += [conversation.assignee] if conversation.assignee.present? + + notification_type = { + 'frt' => 'sla_missed_first_response', + 'nrt' => 'sla_missed_next_response', + 'rt' => 'sla_missed_resolution' + }[event_type] + + notify_users.uniq.each do |user| + NotificationBuilder.new( + notification_type: notification_type, + user: user, + account: account, + primary_actor: conversation, + secondary_actor: sla_policy + ).perform + end + end +end diff --git a/enterprise/app/services/sla/evaluate_applied_sla_service.rb b/enterprise/app/services/sla/evaluate_applied_sla_service.rb index d6eb9839e..4cc953899 100644 --- a/enterprise/app/services/sla/evaluate_applied_sla_service.rb +++ b/enterprise/app/services/sla/evaluate_applied_sla_service.rb @@ -7,8 +7,8 @@ class Sla::EvaluateAppliedSlaService # We will calculate again in the next iteration return unless applied_sla.conversation.resolved? - # No SLA missed, so marking as hit as conversation is resolved - handle_hit_sla(applied_sla) if applied_sla.active? + # after conversation is resolved, we will check if the SLA was hit or missed + handle_hit_sla(applied_sla) end private @@ -49,6 +49,14 @@ class Sla::EvaluateAppliedSlaService handle_missed_sla(applied_sla, 'nrt') end + def get_last_message_id(conversation) + conversation.messages.where(message_type: :incoming).last&.id + end + + def already_missed?(applied_sla, type, meta = {}) + SlaEvent.exists?(applied_sla: applied_sla, event_type: type, meta: meta) + end + def check_resolution_time_threshold(applied_sla, conversation, sla_policy) return if conversation.resolved? @@ -58,48 +66,41 @@ class Sla::EvaluateAppliedSlaService handle_missed_sla(applied_sla, 'rt') end - def handle_missed_sla(applied_sla, type) - return unless applied_sla.active? + def handle_missed_sla(applied_sla, type, meta = {}) + meta = { message_id: get_last_message_id(applied_sla.conversation) } if type == 'nrt' + return if already_missed?(applied_sla, type, meta) - applied_sla.update!(sla_status: 'missed') - generate_notifications_for_sla(applied_sla, type) - Rails.logger.warn "SLA missed for conversation #{applied_sla.conversation.id} " \ + create_sla_event(applied_sla, type, meta) + Rails.logger.warn "SLA #{type} missed for conversation #{applied_sla.conversation.id} " \ "in account #{applied_sla.account_id} " \ "for sla_policy #{applied_sla.sla_policy.id}" + + applied_sla.update!(sla_status: 'active_with_misses') if applied_sla.sla_status != 'active_with_misses' end def handle_hit_sla(applied_sla) - return unless applied_sla.active? - - applied_sla.update!(sla_status: 'hit') - Rails.logger.info "SLA hit for conversation #{applied_sla.conversation.id} " \ - "in account #{applied_sla.account_id} " \ - "for sla_policy #{applied_sla.sla_policy.id}" - end - - def generate_notifications_for_sla(applied_sla, type) - notify_users = applied_sla.conversation.conversation_participants.map(&:user) - # add all admins from the account to notify list - notify_users += applied_sla.account.administrators - # ensure conversation assignee is notified - notify_users += [applied_sla.conversation.assignee] if applied_sla.conversation.assignee.present? - - notification_type = if type == 'frt' - 'sla_missed_first_response' - elsif type == 'nrt' - 'sla_missed_next_response' - else - 'sla_missed_resolution' - end - - notify_users.uniq.each do |user| - NotificationBuilder.new( - notification_type: notification_type, - user: user, - account: applied_sla.account, - primary_actor: applied_sla.conversation, - secondary_actor: applied_sla.sla_policy - ).perform + if applied_sla.active? + applied_sla.update!(sla_status: 'hit') + Rails.logger.info "SLA hit for conversation #{applied_sla.conversation.id} " \ + "in account #{applied_sla.account_id} " \ + "for sla_policy #{applied_sla.sla_policy.id}" + else + applied_sla.update!(sla_status: 'missed') + Rails.logger.info "SLA missed for conversation #{applied_sla.conversation.id} " \ + "in account #{applied_sla.account_id} " \ + "for sla_policy #{applied_sla.sla_policy.id}" end end + + def create_sla_event(applied_sla, event_type, meta = {}) + SlaEvent.create!( + applied_sla: applied_sla, + conversation: applied_sla.conversation, + event_type: event_type, + meta: meta, + account: applied_sla.account, + inbox: applied_sla.conversation.inbox, + sla_policy: applied_sla.sla_policy + ) + end end diff --git a/enterprise/app/views/api/v1/accounts/applied_slas/download.csv.erb b/enterprise/app/views/api/v1/accounts/applied_slas/download.csv.erb new file mode 100644 index 000000000..676d6d680 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/applied_slas/download.csv.erb @@ -0,0 +1,26 @@ +<% headers = [ + I18n.t('reports.sla_csv.conversation_id'), + I18n.t('reports.sla_csv.sla_policy_breached'), + I18n.t('reports.sla_csv.assignee'), + I18n.t('reports.sla_csv.team'), + I18n.t('reports.sla_csv.inbox'), + I18n.t('reports.sla_csv.labels'), + I18n.t('reports.sla_csv.conversation_link'), + I18n.t('reports.sla_csv.breached_events') +] %> +<%= CSV.generate_line headers %> + +<% @breached_slas.each do |sla| %> + <% breached_events = sla.sla_events.map(&:event_type).join(', ') %> + <% conversation = sla.conversation %> + <%= CSV.generate_line([ + conversation.display_id, + sla.sla_policy.name, + conversation.assignee&.name, + conversation.team&.name, + conversation.inbox&.name, + conversation.cached_label_list, + app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id), + breached_events + ]) %> +<% end %> diff --git a/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder new file mode 100644 index 000000000..e9a905d56 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder @@ -0,0 +1,14 @@ +json.array! @applied_slas do |applied_sla| + json.id applied_sla.id + json.sla_policy_id applied_sla.sla_policy_id + json.conversation_id applied_sla.conversation_id + json.sla_status applied_sla.sla_status + json.created_at applied_sla.created_at + json.updated_at applied_sla.updated_at + json.conversation do + json.partial! 'api/v1/models/conversation', conversation: applied_sla.conversation + end + json.sla_events applied_sla.sla_events do |sla_event| + json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event + end +end diff --git a/enterprise/app/views/api/v1/accounts/applied_slas/metrics.json.jbuilder b/enterprise/app/views/api/v1/accounts/applied_slas/metrics.json.jbuilder new file mode 100644 index 000000000..13f184845 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/applied_slas/metrics.json.jbuilder @@ -0,0 +1,3 @@ +json.total_applied_slas @total_applied_slas +json.number_of_sla_breaches @number_of_sla_breaches +json.hit_rate @hit_rate diff --git a/enterprise/app/views/api/v1/models/_sla_event.json.jbuilder b/enterprise/app/views/api/v1/models/_sla_event.json.jbuilder new file mode 100644 index 000000000..e51defc0a --- /dev/null +++ b/enterprise/app/views/api/v1/models/_sla_event.json.jbuilder @@ -0,0 +1,5 @@ +json.id sla_event.id +json.event_type sla_event.event_type +json.meta sla_event.meta +json.updated_at sla_event.updated_at.to_i +json.created_at sla_event.created_at.to_i diff --git a/lib/automation_rules/conditions.json b/lib/automation_rules/conditions.json deleted file mode 100644 index ba4bd4b5b..000000000 --- a/lib/automation_rules/conditions.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "conversations": { - "status": { - "attribute_name": "Status", - "input_type": "multi_select", - "table_name": "conversations", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "standard" - }, - "assignee_id": { - "attribute_name": "Assignee Name", - "input_type": "search_box with name tags/plain text", - "table_name": "conversations", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "contact_id": { - "attribute_name": "Contact Name", - "input_type": "plain_text", - "table_name": "conversations", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "inbox_id": { - "attribute_name": "Inbox Name", - "input_type": "search_box", - "table_name": "conversations", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "team_id": { - "attribute_name": "Team Name", - "input_type": "search_box", - "table_name": "conversations", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "id": { - "attribute_name": "Conversation Identifier", - "input_type": "textbox", - "table_name": "conversations", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "campaign_id": { - "attribute_name": "Campaign Name", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "labels": { - "attribute_name": "Labels", - "input_type": "tags", - "data_type": "text", - "filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "browser_language": { - "attribute_name": "Browser Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "conversation_language": { - "attribute_name": "Conversation Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "additional_attributes" - }, - "mail_subject": { - "attribute_name": "Email Subject", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "country_code": { - "attribute_name": "Country Name", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - }, - "referer": { - "attribute_name": "Referer link", - "input_type": "textbox", - "data_type": "link", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - }, - "plan": { - "attribute_name": "Plan", - "input_type": "multi_select", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - } - }, - "contacts": { - "assignee_id": { - "attribute_name": "Assignee Name", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "phone_number": { - "attribute_name": "Phone Number", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with" ], - "attribute_type": "standard" - }, - "contact_id": { - "attribute_name": "Contact Name", - "input_type": "plain_text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "inbox_id": { - "attribute_name": "Inbox Name", - "input_type": "search_box", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "team_id": { - "attribute_name": "Team Name", - "input_type": "search_box", - "data_type": "number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "id": { - "attribute_name": "Conversation Identifier", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "campaign_id": { - "attribute_name": "Campaign Name", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "labels": { - "attribute_name": "Labels", - "input_type": "tags", - "data_type": "text", - "filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "browser_language": { - "attribute_name": "Browser Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "mail_subject": { - "attribute_name": "Email Subject", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "email": { - "attribute_name": "Email", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "country_code": { - "attribute_name": "Country Name", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - }, - "referer": { - "attribute_name": "Referer link", - "input_type": "textbox", - "data_type": "link", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - } - } -} diff --git a/lib/custom_exceptions/custom_filter.rb b/lib/custom_exceptions/custom_filter.rb new file mode 100644 index 000000000..03ff9ec7a --- /dev/null +++ b/lib/custom_exceptions/custom_filter.rb @@ -0,0 +1,19 @@ +module CustomExceptions::CustomFilter + class InvalidAttribute < CustomExceptions::Base + def message + I18n.t('errors.custom_filters.invalid_attribute', key: @data[:key], allowed_keys: @data[:allowed_keys].join(',')) + end + end + + class InvalidOperator < CustomExceptions::Base + def message + I18n.t('errors.custom_filters.invalid_operator', attribute_name: @data[:attribute_name], allowed_keys: @data[:allowed_keys].join(',')) + end + end + + class InvalidValue < CustomExceptions::Base + def message + I18n.t('errors.custom_filters.invalid_value', attribute_name: @data[:attribute_name]) + end + end +end diff --git a/lib/filters/conversation_filters.json b/lib/filters/conversation_filters.json deleted file mode 100644 index 39f58f5c6..000000000 --- a/lib/filters/conversation_filters.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "conversations": [ - { - "attribute_key": "status", - "attribute_name": "Status", - "input_type": "multi_select", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "standard" - }, - { - "attribute_key": "assigne", - "attribute_name": "Assignee Name", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - { - "attribute_key": "contact", - "attribute_name": "Contact Name", - "input_type": "plain_text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - { - "attribute_key": "inbox", - "attribute_name": "Inbox Name", - "input_type": "search_box", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - { - "attribute_key": "team_id", - "attribute_name": "Team Name", - "input_type": "search_box", - "data_type": "number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - { - "attribute_key": "id", - "attribute_name": "Conversation Identifier", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - { - "attribute_key": "campaign_id", - "attribute_name": "Campaign Name", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - { - "attribute_key": "labels", - "attribute_name": "Labels", - "input_type": "tags", - "data_type": "text", - "filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - { - "attribute_key": "browser_language", - "attribute_name": "Browser Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - { - "attribute_key": "country_code", - "attribute_name": "Country Name", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - }, - { - "attribute_key": "referer", - "attribute_name": "Referer link", - "input_type": "textbox", - "data_type": "link", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - } - ] -} diff --git a/lib/filters/filter_keys.json b/lib/filters/filter_keys.json deleted file mode 100644 index 9266d9bea..000000000 --- a/lib/filters/filter_keys.json +++ /dev/null @@ -1,204 +0,0 @@ -{ - "conversations": { - "status": { - "attribute_name": "Status", - "input_type": "multi_select", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "standard" - }, - "assignee_id": { - "attribute_name": "Assignee Name", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "contact_id": { - "attribute_name": "Contact Name", - "input_type": "plain_text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "inbox_id": { - "attribute_name": "Inbox Name", - "input_type": "search_box", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "team_id": { - "attribute_name": "Team Name", - "input_type": "search_box", - "data_type": "number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "display_id": { - "attribute_name": "Conversation Identifier", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "campaign_id": { - "attribute_name": "Campaign Name", - "input_type": "textbox", - "data_type": "Number", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present" ], - "attribute_type": "standard" - }, - "labels": { - "attribute_name": "Labels", - "input_type": "tags", - "data_type": "text", - "filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "browser_language": { - "attribute_name": "Browser Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "conversation_language": { - "attribute_name": "Conversation Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "additional_attributes" - }, - "country_code": { - "attribute_name": "Country Name", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - }, - "referer": { - "attribute_name": "Referer link", - "input_type": "textbox", - "data_type": "link", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "present", "is_not_present" ], - "attribute_type": "additional_attributes" - }, - "created_at": { - "attribute_name": "Created At", - "input_type": "date", - "data_type": "date", - "filter_operators": [ "is_greater_than", "is_less_than", "days_before" ], - "attribute_type": "date_attributes" - }, - "last_activity_at": { - "attribute_name": "Created At", - "input_type": "date", - "data_type": "date", - "filter_operators": [ "is_greater_than", "is_less_than", "days_before" ], - "attribute_type": "date_attributes" - }, - "mail_subject": { - "attribute_name": "Email Subject", - "input_type": "text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain"], - "attribute_type": "additional_attributes" - } - }, - "contacts": { - "name": { - "attribute_name": "Name", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "phone_number": { - "attribute_name": "Phone Number", - "input_type": "text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"], - "attribute_type": "standard" - }, - "email": { - "attribute_name": "Email", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "identifier": { - "attribute_name": "Contact Identifier", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "standard" - }, - "country_code": { - "attribute_name": "Country", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "additional_attributes" - }, - "city": { - "attribute_name": "City", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "browser_language": { - "attribute_name": "Browser Language", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "company": { - "attribute_name": "Company", - "input_type": "textbox", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "additional_attributes" - }, - "labels": { - "attribute_name": "Labels", - "input_type": "tags", - "data_type": "text", - "filter_operators": ["exactly_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - }, - "created_at": { - "attribute_name": "Created At", - "input_type": "date", - "data_type": "date", - "filter_operators": [ "is_greater_than", "is_less_than", "days_before" ], - "attribute_type": "date_attributes" - }, - "last_activity_at": { - "attribute_name": "Created At", - "input_type": "date", - "data_type": "date", - "filter_operators": [ "is_greater_than", "is_less_than", "days_before" ], - "attribute_type": "date_attributes" - } - }, - "messages": { - "message_type": { - "attribute_name": "Message Type", - "input_type": "search_box with name tags/plain text", - "data_type": "numeric", - "filter_operators": [ "equal_to", "not_equal_to" ], - "attribute_type": "standard" - }, - "content": { - "attribute_name": "Message Content", - "input_type": "search_box with name tags/plain text", - "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], - "attribute_type": "standard" - } - } -} diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml new file mode 100644 index 000000000..598ab84d6 --- /dev/null +++ b/lib/filters/filter_keys.yml @@ -0,0 +1,226 @@ +## This file contains the filter configurations which we use for the following +# 1. Conversation Filters (app/services/filter_service.rb) +# 2. Contact Filters (app/services/filter_service.rb) +# 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb) + + +# Format +# - Parent Key (conversation, contact, messages) +# - Key (attribute_name) +# - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"] +# - data_type: "text" : supported ["text", "text_case_insensitive", "number", "boolean", "labels", "date", "link"] +# - filter_operators: ["equal_to", "not_equal_to", "contains", "does_not_contain", "is_present", "is_not_present", "is_greater_than", "is_less_than", "days_before", "starts_with"] + +### ----- Conversation Filters ----- ### + +conversations: + status: + attribute_type: "standard" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + assignee_id: + attribute_type: "standard" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + - "is_present" + - "is_not_present" + inbox_id: + attribute_type: "standard" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + - "is_present" + - "is_not_present" + team_id: + attribute_type: "standard" + data_type: "number" + filter_operators: + - "equal_to" + - "not_equal_to" + - "is_present" + - "is_not_present" + display_id: + attribute_type: "standard" + data_type: "Number" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + campaign_id: + attribute_type: "standard" + data_type: "Number" + filter_operators: + - "equal_to" + - "not_equal_to" + - "is_present" + - "is_not_present" + labels: + attribute_type: "standard" + data_type: "labels" + filter_operators: + - "equal_to" + - "not_equal_to" + - "is_present" + - "is_not_present" + browser_language: + attribute_type: "additional_attributes" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + conversation_language: + attribute_type: "additional_attributes" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + country_code: + attribute_type: "additional_attributes" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + referer: + attribute_type: "additional_attributes" + data_type: "link" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + created_at: + attribute_type: "standard" + data_type: "date" + filter_operators: + - "is_greater_than" + - "is_less_than" + - "days_before" + last_activity_at: + attribute_type: "standard" + data_type: "date" + filter_operators: + - "is_greater_than" + - "is_less_than" + - "days_before" + mail_subject: + attribute_type: "additional_attributes" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + +### ----- End of Conversation Filters ----- ### + + +### ----- Contact Filters ----- ### +contacts: + name: + attribute_type: "standard" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + phone_number: + attribute_type: "standard" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + - "starts_with" + email: + attribute_type: "standard" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + identifier: + attribute_type: "standard" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + country_code: + attribute_type: "additional_attributes" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + city: + attribute_type: "additional_attributes" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + company: + attribute_type: "additional_attributes" + data_type: "text_case_insensitive" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + labels: + attribute_type: "standard" + data_type: "labels" + filter_operators: + - "equal_to" + - "not_equal_to" + - "is_present" + - "is_not_present" + created_at: + attribute_type: "standard" + data_type: "date" + filter_operators: + - "is_greater_than" + - "is_less_than" + - "days_before" + last_activity_at: + attribute_type: "standard" + data_type: "date" + filter_operators: + - "is_greater_than" + - "is_less_than" + - "days_before" + blocked: + attribute_type: "standard" + data_type: "boolean" + filter_operators: + - "equal_to" + - "not_equal_to" + +### ----- End of Contact Filters ----- ### + +### ----- Message Filters ----- ### +messages: + message_type: + attribute_type: "standard" + data_type: "numeric" + filter_operators: + - "equal_to" + - "not_equal_to" + content: + attribute_type: "standard" + data_type: "text" + filter_operators: + - "equal_to" + - "not_equal_to" + - "contains" + - "does_not_contain" + +### ----- End of Message Filters ----- ### diff --git a/package.json b/package.json index ac353f186..313821424 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "3.6.0", + "version": "3.7.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", @@ -63,7 +63,7 @@ "libphonenumber-js": "^1.10.24", "logrocket": "^3.0.1", "logrocket-vuex": "^0.0.3", - "markdown-it": "^13.0.1", + "markdown-it": "^13.0.2", "markdown-it-link-attributes": "^4.0.1", "md5": "^2.3.0", "ninja-keys": "^1.2.2", diff --git a/spec/builders/agent_builder_spec.rb b/spec/builders/agent_builder_spec.rb index 9d7667306..ac8a3229a 100644 --- a/spec/builders/agent_builder_spec.rb +++ b/spec/builders/agent_builder_spec.rb @@ -67,21 +67,5 @@ RSpec.describe AgentBuilder, type: :model do expect(user.encrypted_password).not_to be_empty end end - - context 'with confirmation required' do - let(:unconfirmed_user) { create(:user, email: email) } - - before do - unconfirmed_user.confirmed_at = nil - unconfirmed_user.save(validate: false) - allow(unconfirmed_user).to receive(:confirmed?).and_return(false) - end - - it 'sends confirmation instructions' do - user = agent_builder.perform - expect(user).to receive(:send_confirmation_instructions) - agent_builder.send(:send_confirmation_if_required) - end - end end end diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index d2527e6a9..37e64357f 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -338,14 +338,18 @@ RSpec.describe 'Contacts API', type: :request do context 'when it is an authenticated user' do let(:admin) { create(:user, account: account, role: :administrator) } - let!(:contact1) { create(:contact, :with_email, account: account) } - let!(:contact2) { create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com') } + let!(:contact1) { create(:contact, :with_email, account: account, additional_attributes: { country_code: 'US' }) } + let!(:contact2) do + create(:contact, :with_email, name: 'testcontact', account: account, email: 'test@test.com', additional_attributes: { country_code: 'US' }) + end it 'returns all contacts when query is empty' do post "/api/v1/accounts/#{account.id}/contacts/filter", - params: { - payload: [] - }, + params: { payload: [ + attribute_key: 'country_code', + filter_operator: 'equal_to', + values: ['US'] + ] }, headers: admin.create_new_auth_token, as: :json @@ -353,6 +357,34 @@ RSpec.describe 'Contacts API', type: :request do expect(response.body).to include(contact2.email) expect(response.body).to include(contact1.email) end + + it 'returns error the query operator is invalid' do + post "/api/v1/accounts/#{account.id}/contacts/filter", + params: { payload: [ + attribute_key: 'country_code', + filter_operator: 'eq', + values: ['US'] + ] }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('Invalid operator. The allowed operators for country_code are [equal_to,not_equal_to]') + end + + it 'returns error the query value is invalid' do + post "/api/v1/accounts/#{account.id}/contacts/filter", + params: { payload: [ + attribute_key: 'country_code', + filter_operator: 'equal_to', + values: [] + ] }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include('Invalid value. The values provided for country_code are invalid"') + end end end diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 70f5b82ee..d93886fc3 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -152,17 +152,56 @@ RSpec.describe 'Conversations API', type: :request do create(:inbox_member, user: agent, inbox: conversation.inbox) end - it 'returns all conversations with empty query' do + it 'returns all conversations matching the query' do post "/api/v1/accounts/#{account.id}/conversations/filter", headers: agent.create_new_auth_token, - params: { payload: [] }, + params: { + payload: [{ + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['open'] + }] + }, as: :json expect(response).to have_http_status(:success) response_data = JSON.parse(response.body, symbolize_names: true) - expect(response_data.count).to eq(2) end + + it 'returns error if the filters contain invalid attributes' do + post "/api/v1/accounts/#{account.id}/conversations/filter", + headers: agent.create_new_auth_token, + params: { + payload: [{ + attribute_key: 'phone_number', + filter_operator: 'equal_to', + values: ['open'] + }] + }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + response_data = JSON.parse(response.body, symbolize_names: true) + expect(response_data[:error]).to include('Invalid attribute key - [phone_number]') + end + + it 'returns error if the filters contain invalid operator' do + post "/api/v1/accounts/#{account.id}/conversations/filter", + headers: agent.create_new_auth_token, + params: { + payload: [{ + attribute_key: 'status', + filter_operator: 'eq', + values: ['open'] + }] + }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + response_data = JSON.parse(response.body, symbolize_names: true) + expect(response_data[:error]).to eq('Invalid operator. The allowed operators for status are [equal_to,not_equal_to].') + end end end diff --git a/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb new file mode 100644 index 000000000..3945c8c93 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb @@ -0,0 +1,218 @@ +require 'rails_helper' + +RSpec.describe 'Applied SLAs API', type: :request do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + let(:agent1) { create(:user, account: account, role: :agent) } + let(:agent2) { create(:user, account: account, role: :agent) } + let(:conversation1) { create(:conversation, account: account, assignee: agent1) } + let(:conversation2) { create(:conversation, account: account, assignee: agent2) } + let(:conversation3) { create(:conversation, account: account, assignee: agent2) } + let(:sla_policy1) { create(:sla_policy, account: account) } + let(:sla_policy2) { create(:sla_policy, account: account) } + + before do + AppliedSla.destroy_all + end + + describe 'GET /api/v1/accounts/{account.id}/applied_slas/metrics' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/applied_slas/metrics" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'returns the sla metrics' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed') + + get "/api/v1/accounts/#{account.id}/applied_slas/metrics", + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body).to include('total_applied_slas' => 1) + expect(body).to include('number_of_sla_breaches' => 1) + expect(body).to include('hit_rate' => '0.0%') + end + + it 'filters sla metrics based on a date range' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) + + get "/api/v1/accounts/#{account.id}/applied_slas/metrics", + params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body).to include('total_applied_slas' => 1) + expect(body).to include('number_of_sla_breaches' => 0) + expect(body).to include('hit_rate' => '100%') + end + + it 'filters sla metrics based on a date range and agent ids' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed') + + get "/api/v1/accounts/#{account.id}/applied_slas/metrics", + params: { agent_ids: [agent2.id] }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body).to include('total_applied_slas' => 3) + expect(body).to include('number_of_sla_breaches' => 1) + expect(body).to include('hit_rate' => '66.67%') + end + + it 'filters sla metrics based on sla policy ids' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed') + create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2, sla_status: 'missed') + + get "/api/v1/accounts/#{account.id}/applied_slas/metrics", + params: { sla_policy_id: sla_policy1.id }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body).to include('total_applied_slas' => 2) + expect(body).to include('number_of_sla_breaches' => 1) + expect(body).to include('hit_rate' => '50.0%') + end + + it 'filters sla metrics based on labels' do + conversation2.update_labels('label1') + conversation3.update_labels('label1') + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed') + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago) + + get "/api/v1/accounts/#{account.id}/applied_slas/metrics", + params: { label_list: ['label1'] }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body).to include('total_applied_slas' => 2) + expect(body).to include('number_of_sla_breaches' => 1) + expect(body).to include('hit_rate' => '50.0%') + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/applied_slas/download' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/applied_slas/download" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'returns a CSV file with breached conversations' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed') + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed') + conversation1.update(status: 'open') + conversation2.update(status: 'resolved') + + get "/api/v1/accounts/#{account.id}/applied_slas/download", + headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:success) + expect(response.headers['Content-Type']).to eq('text/csv') + expect(response.headers['Content-Disposition']).to include('attachment; filename=breached_conversation.csv') + + csv_data = CSV.parse(response.body) + csv_data.reject! { |row| row.all?(&:nil?) } + expect(csv_data.size).to eq(2) + expect(csv_data[1][0].to_i).to eq(conversation1.display_id) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/applied_slas' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/applied_slas" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'returns the applied slas' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2) + get "/api/v1/accounts/#{account.id}/applied_slas", + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body.size).to eq(2) + expect(body.first).to include('id') + expect(body.first).to include('sla_policy_id' => sla_policy1.id) + expect(body.first).to include('conversation_id' => conversation1.id) + end + + it 'filters applied slas based on a date range' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) + + get "/api/v1/accounts/#{account.id}/applied_slas", + params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body.size).to eq(1) + end + + it 'filters applied slas based on a date range and agent ids' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) + + get "/api/v1/accounts/#{account.id}/applied_slas", + params: { agent_ids: [agent2.id] }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body.size).to eq(3) + end + + it 'filters applied slas based on sla policy ids' do + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2) + create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2) + + get "/api/v1/accounts/#{account.id}/applied_slas", + params: { sla_policy_id: sla_policy1.id }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body.size).to eq(2) + end + + it 'filters applied slas based on labels' do + conversation2.update_labels('label1') + conversation3.update_labels('label1') + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago) + create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago) + + get "/api/v1/accounts/#{account.id}/applied_slas", + params: { label_list: ['label1'] }, + headers: administrator.create_new_auth_token + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + expect(body.size).to eq(2) + end + end + end +end diff --git a/spec/enterprise/drops/sla_policy_drop_spec.rb b/spec/enterprise/drops/sla_policy_drop_spec.rb new file mode 100644 index 000000000..c1be13c70 --- /dev/null +++ b/spec/enterprise/drops/sla_policy_drop_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe SlaPolicyDrop do + subject(:sla_policy_drop) { described_class.new(sla_policy) } + + let!(:sla_policy) { create(:sla_policy) } + + it 'returns name' do + expect(sla_policy_drop.name).to eq sla_policy.name + end + + it 'returns description' do + expect(sla_policy_drop.description).to eq sla_policy.description + end +end diff --git a/spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb b/spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb index beae967db..5d628f71e 100644 --- a/spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb +++ b/spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb @@ -7,18 +7,20 @@ RSpec.describe Sla::ProcessAccountAppliedSlasJob do let!(:applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'active') } let!(:hit_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'hit') } let!(:miss_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'missed') } + let!(:active_with_misses_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'active_with_misses') } it 'enqueues the job' do expect { described_class.perform_later }.to have_enqueued_job(described_class) .on_queue('medium') end - it 'calls the ProcessAppliedSlaJob' do + it 'calls the ProcessAppliedSlaJob for both active and active_with_misses' do + expect(Sla::ProcessAppliedSlaJob).to receive(:perform_later).with(active_with_misses_applied_sla).and_call_original expect(Sla::ProcessAppliedSlaJob).to receive(:perform_later).with(applied_sla).and_call_original described_class.perform_now(account) end - it 'does not call the ProcessAppliedSlaJob for not active applied slas' do + it 'does not call the ProcessAppliedSlaJob for applied slas that are hit or miss' do expect(Sla::ProcessAppliedSlaJob).not_to receive(:perform_later).with(hit_applied_sla) expect(Sla::ProcessAppliedSlaJob).not_to receive(:perform_later).with(miss_applied_sla) described_class.perform_now(account) diff --git a/spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb b/spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb new file mode 100644 index 000000000..e5e2b14da --- /dev/null +++ b/spec/enterprise/mailers/enterprise/agent_notifications/conversation_notifications_mailer_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +# rails helper is using infer filetype to detect rspec type +# so we need to include type: :mailer to make this test work in enterprise namespace +RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :mailer do + let(:class_instance) { described_class.new } + let!(:account) { create(:account) } + let(:agent) { create(:user, email: 'agent1@example.com', account: account) } + let(:conversation) { create(:conversation, assignee: agent, account: account) } + + before do + allow(described_class).to receive(:new).and_return(class_instance) + allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) + end + + describe 'sla_missed_first_response' do + let(:sla_policy) { create(:sla_policy, account: account) } + let(:mail) { described_class.with(account: account).sla_missed_first_response(conversation, agent, sla_policy).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for first response") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([agent.email]) + end + end + + describe 'sla_missed_next_response' do + let(:sla_policy) { create(:sla_policy, account: account) } + let(:mail) { described_class.with(account: account).sla_missed_next_response(conversation, agent, sla_policy).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for next response") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([agent.email]) + end + end + + describe 'sla_missed_resolution' do + let(:sla_policy) { create(:sla_policy, account: account) } + let(:mail) { described_class.with(account: account).sla_missed_resolution(conversation, agent, sla_policy).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for resolution time") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([agent.email]) + end + end +end diff --git a/spec/enterprise/models/sla_event_spec.rb b/spec/enterprise/models/sla_event_spec.rb new file mode 100644 index 000000000..3c44d3961 --- /dev/null +++ b/spec/enterprise/models/sla_event_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe SlaEvent, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:applied_sla) } + it { is_expected.to belong_to(:conversation) } + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:sla_policy) } + it { is_expected.to belong_to(:inbox) } + end + + describe 'validates_factory' do + it 'creates valid sla event object' do + sla_event = create(:sla_event) + expect(sla_event.event_type).to eq 'frt' + end + end + + describe 'backfilling ids' do + it 'automatically backfills account_id, inbox_id, and sla_id upon creation' do + sla_event = create(:sla_event) + + expect(sla_event.account_id).to eq sla_event.conversation.account_id + expect(sla_event.inbox_id).to eq sla_event.conversation.inbox_id + expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id + end + end + + describe 'create notifications' do + # create account, user and inbox + let!(:account) { create(:account) } + let!(:assignee) { create(:user, account: account) } + let!(:participant) { create(:user, account: account) } + let!(:admin) { create(:user, account: account, role: :administrator) } + let!(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, inbox: inbox, assignee: assignee, account: account) } + let(:sla_policy) { create(:sla_policy, account: conversation.account) } + let(:sla_event) { create(:sla_event, event_type: 'frt', conversation: conversation, sla_policy: sla_policy) } + + before do + # to ensure notifications are not sent to other users + create(:user, account: account) + create(:inbox_member, inbox: inbox, user: participant) + create(:conversation_participant, conversation: conversation, user: participant) + end + + it 'creates notifications for conversation participants, admins, and assignee' do + sla_event + + expect(Notification.count).to eq(3) + # check if notification type is sla_missed_first_response + expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(3) + # Check if notification is created for the assignee + expect(Notification.where(user_id: assignee.id).count).to eq(1) + # Check if notification is created for the account admin + expect(Notification.where(user_id: admin.id).count).to eq(1) + # Check if notification is created for participant + expect(Notification.where(user_id: participant.id).count).to eq(1) + end + end +end diff --git a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb index 12cb59d35..f6cd657be 100644 --- a/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb +++ b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb @@ -3,8 +3,6 @@ require 'rails_helper' RSpec.describe Sla::EvaluateAppliedSlaService do let!(:account) { create(:account) } let!(:user_1) { create(:user, account: account) } - let!(:user_2) { create(:user, account: account) } - let!(:admin) { create(:user, account: account, role: :administrator) } let!(:sla_policy) do create(:sla_policy, @@ -28,19 +26,17 @@ RSpec.describe Sla::EvaluateAppliedSlaService do it 'updates the SLA status to missed and logs a warning' do allow(Rails.logger).to receive(:warn) described_class.new(applied_sla: applied_sla).perform - expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \ + expect(Rails.logger).to have_received(:warn).with("SLA frt missed for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") - expect(applied_sla.reload.sla_status).to eq('missed') + expect(applied_sla.reload.sla_status).to eq('active_with_misses') + end - expect(Notification.count).to eq(2) - # check if notification type is sla_missed_first_response - expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(2) - # Check if notification is created for the assignee - expect(Notification.where(user_id: user_1.id).count).to eq(1) - # Check if notification is created for the account admin - expect(Notification.where(user_id: admin.id).count).to eq(1) - # Check if no notification is created for other user - expect(Notification.where(user_id: user_2.id).count).to eq(0) + it 'creates SlaEvent only for frt miss' do + described_class.new(applied_sla: applied_sla).perform + + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(1) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(0) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(0) end end @@ -53,19 +49,17 @@ RSpec.describe Sla::EvaluateAppliedSlaService do it 'updates the SLA status to missed and logs a warning' do allow(Rails.logger).to receive(:warn) described_class.new(applied_sla: applied_sla).perform - expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \ + expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") - expect(applied_sla.reload.sla_status).to eq('missed') + expect(applied_sla.reload.sla_status).to eq('active_with_misses') + end - expect(Notification.count).to eq(2) - # check if notification type is sla_missed_first_response - expect(Notification.where(notification_type: 'sla_missed_next_response').count).to eq(2) - # Check if notification is created for the assignee - expect(Notification.where(user_id: user_1.id).count).to eq(1) - # Check if notification is created for the account admin - expect(Notification.where(user_id: admin.id).count).to eq(1) - # Check if no notification is created for other user - expect(Notification.where(user_id: user_2.id).count).to eq(0) + it 'creates SlaEvent only for nrt miss' do + described_class.new(applied_sla: applied_sla).perform + + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(1) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(0) end end @@ -75,18 +69,18 @@ RSpec.describe Sla::EvaluateAppliedSlaService do it 'updates the SLA status to missed and logs a warning' do allow(Rails.logger).to receive(:warn) described_class.new(applied_sla: applied_sla).perform - expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \ + expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") - expect(applied_sla.reload.sla_status).to eq('missed') - expect(Notification.count).to eq(2) - expect(Notification.where(notification_type: 'sla_missed_resolution').count).to eq(2) - # Check if notification is created for the assignee - expect(Notification.where(user_id: user_1.id).count).to eq(1) - # Check if notification is created for the account admin - expect(Notification.where(user_id: admin.id).count).to eq(1) - # Check if no notification is created for other user - expect(Notification.where(user_id: user_2.id).count).to eq(0) + expect(applied_sla.reload.sla_status).to eq('active_with_misses') + end + + it 'creates SlaEvent only for rt miss' do + described_class.new(applied_sla: applied_sla).perform + + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(0) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1) end end @@ -110,13 +104,14 @@ RSpec.describe Sla::EvaluateAppliedSlaService do conversation.update(first_reply_created_at: 5.hours.ago, waiting_since: 5.hours.ago) end - it 'updates the SLA status to missed and logs a warning' do + it 'updates the SLA status to missed and logs multiple warnings' do allow(Rails.logger).to receive(:warn) described_class.new(applied_sla: applied_sla).perform - expect(Rails.logger).to have_received(:warn).with("SLA missed for conversation #{conversation.id} in account " \ + expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time - expect(applied_sla.reload.sla_status).to eq('missed') - expect(Notification.count).to eq(2) + expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \ + "#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time + expect(applied_sla.reload.sla_status).to eq('active_with_misses') end end end @@ -140,6 +135,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") expect(applied_sla.reload.sla_status).to eq('hit') + expect(SlaEvent.count).to eq(0) expect(Notification.count).to eq(0) end end @@ -162,6 +158,7 @@ RSpec.describe Sla::EvaluateAppliedSlaService do expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") expect(applied_sla.reload.sla_status).to eq('hit') + expect(SlaEvent.count).to eq(0) end end @@ -177,7 +174,52 @@ RSpec.describe Sla::EvaluateAppliedSlaService do expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \ "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") expect(applied_sla.reload.sla_status).to eq('hit') + expect(SlaEvent.count).to eq(0) end end end + + describe 'SLA evaluation with frt hit, multiple nrt misses and rt miss' do + before do + # Setup SLA Policy thresholds + sla_policy.update( + first_response_time_threshold: 2.hours, # Hit frt + next_response_time_threshold: 1.hour, # Miss nrt multiple times + resolution_time_threshold: 4.hours # Miss rt + ) + + # Simulate conversation timeline + # Hit frt + # incoming message from customer + create(:message, conversation: conversation, created_at: 6.hours.ago, message_type: :incoming) + # outgoing message from agent within frt + create(:message, conversation: conversation, created_at: 5.hours.ago, message_type: :outgoing) + + # Miss nrt first time + create(:message, conversation: conversation, created_at: 4.hours.ago, message_type: :incoming) + described_class.new(applied_sla: applied_sla).perform + + # Miss nrt second time + create(:message, conversation: conversation, created_at: 3.hours.ago, message_type: :incoming) + described_class.new(applied_sla: applied_sla).perform + + # Conversation is resolved missing rt + conversation.update(status: 'resolved') + + # this will not create a new notification for rt miss as conversation is resolved + # but we would have already created an rt miss notification during previous evaluation + described_class.new(applied_sla: applied_sla).perform + end + + it 'updates the SLA status to missed' do + # the status would be missed as the conversation is resolved + expect(applied_sla.reload.sla_status).to eq('missed') + end + + it 'creates necessary sla events' do + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(2) + expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1) + end + end end diff --git a/spec/factories/sla_events.rb b/spec/factories/sla_events.rb new file mode 100644 index 000000000..12be18ede --- /dev/null +++ b/spec/factories/sla_events.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :sla_event do + applied_sla + conversation + event_type { 'frt' } + account { conversation.account } + inbox { conversation.inbox } + sla_policy { applied_sla.sla_policy } + end +end diff --git a/spec/models/automation_rule_spec.rb b/spec/models/automation_rule_spec.rb index a20d3d71d..53ebfa0c7 100644 --- a/spec/models/automation_rule_spec.rb +++ b/spec/models/automation_rule_spec.rb @@ -1,6 +1,11 @@ require 'rails_helper' +require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' RSpec.describe AutomationRule do + describe 'concerns' do + it_behaves_like 'reauthorizable' + end + describe 'associations' do let(:account) { create(:account) } let(:params) do @@ -56,4 +61,35 @@ RSpec.describe AutomationRule do expect(rule.errors.messages[:conditions]).to eq(['Automation conditions should have query operator.']) end end + + describe 'reauthorizable' do + context 'when prompt_reauthorization!' do + it 'marks the rule inactive' do + rule = create(:automation_rule) + expect(rule.active).to be true + rule.prompt_reauthorization! + expect(rule.active).to be false + end + end + + context 'when reauthorization_required?' do + it 'unsets the error count if conditions are updated' do + rule = create(:automation_rule) + rule.prompt_reauthorization! + expect(rule.reauthorization_required?).to be true + + rule.update!(conditions: [{ attribute_key: 'browser_language', filter_operator: 'equal_to', values: ['en'], query_operator: 'AND' }]) + expect(rule.reauthorization_required?).to be false + end + + it 'will not unset the error count if conditions are not updated' do + rule = create(:automation_rule) + rule.prompt_reauthorization! + expect(rule.reauthorization_required?).to be true + + rule.update!(name: 'Updated name') + expect(rule.reauthorization_required?).to be true + end + end + end end diff --git a/spec/models/concerns/reauthorizable_shared.rb b/spec/models/concerns/reauthorizable_shared.rb index 0bfa112c4..9efe232e8 100644 --- a/spec/models/concerns/reauthorizable_shared.rb +++ b/spec/models/concerns/reauthorizable_shared.rb @@ -25,10 +25,19 @@ shared_examples_for 'reauthorizable' do it 'prompt_reauthorization!' do obj = FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) + mailer = double + mailer_method = double + allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(mailer) + # allow mailer to receive any methods and return mailer + allow(mailer).to receive(:method_missing).and_return(mailer_method) + allow(mailer_method).to receive(:deliver_later) + expect(obj.reauthorization_required?).to be false obj.prompt_reauthorization! expect(obj.reauthorization_required?).to be true + expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account) + expect(mailer_method).to have_received(:deliver_later) end it 'reauthorized!' do diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb index 572d2b205..a373cf81e 100644 --- a/spec/services/contacts/filter_service_spec.rb +++ b/spec/services/contacts/filter_service_spec.rb @@ -7,9 +7,9 @@ describe Contacts::FilterService do let!(:first_user) { create(:user, account: account) } let!(:second_user) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } - let(:en_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'en' }) } - let(:el_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'el' }) } - let(:cs_contact) { create(:contact, account: account, additional_attributes: { 'browser_language': 'cs' }) } + let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) } + let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) } + let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) } before do create(:inbox_member, user: first_user, inbox: inbox) @@ -37,6 +37,8 @@ describe Contacts::FilterService do end describe '#perform' do + let!(:params) { { payload: [], page: 1 } } + before do en_contact.update_labels(%w[random_label support]) cs_contact.update_labels('support') @@ -46,90 +48,7 @@ describe Contacts::FilterService do cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' }) end - context 'with query present' do - let!(:params) { { payload: [], page: 1 } } - let(:payload) do - [ - { - attribute_key: 'browser_language', - filter_operator: 'equal_to', - values: ['en'], - query_operator: nil - }.with_indifferent_access - ] - end - - context 'with label filter' do - it 'returns equal_to filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'equal_to', - values: ['support'], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 2 - expect(result[:contacts].first.label_list).to include('support') - expect(result[:contacts].last.label_list).to include('support') - end - - it 'returns not_equal_to filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'not_equal_to', - values: ['support'], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 1 - expect(result[:contacts].first.id).to eq el_contact.id - end - - it 'returns is_present filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'is_present', - values: [], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 2 - expect(result[:contacts].first.label_list).to include('support') - expect(result[:contacts].last.label_list).to include('support') - end - - it 'returns is_not_present filter results properly' do - params[:payload] = [ - { - attribute_key: 'labels', - filter_operator: 'is_not_present', - values: [], - query_operator: nil - }.with_indifferent_access - ] - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be 1 - expect(result[:contacts].first.id).to eq el_contact.id - end - end - - it 'filter contacts by additional_attributes' do - params[:payload] = payload - result = filter_service.new(params, first_user).perform - expect(result[:count]).to be 1 - expect(result[:contacts].first.id).to eq(en_contact.id) - end - + context 'with standard attributes - name' do it 'filter contacts by name' do params[:payload] = [ { @@ -145,7 +64,168 @@ describe Contacts::FilterService do expect(result[:contacts].length).to be 1 expect(result[:contacts].first.name).to eq(en_contact.name) end + end + context 'with standard attributes - blocked' do + it 'filter contacts by blocked' do + blocked_contact = create(:contact, account: account, blocked: true) + params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'], + query_operator: nil }.with_indifferent_access] } + result = filter_service.new(params, first_user).perform + expect(result[:count]).to be 1 + expect(result[:contacts].first.id).to eq(blocked_contact.id) + end + + it 'filter contacts by not_blocked' do + params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: [false], + query_operator: nil }.with_indifferent_access] } + result = filter_service.new(params, first_user).perform + # existing contacts are not blocked + expect(result[:count]).to be 3 + end + end + + context 'with standard attributes - label' do + it 'returns equal_to filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['support'], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 2 + expect(result[:contacts].first.label_list).to include('support') + expect(result[:contacts].last.label_list).to include('support') + end + + it 'returns not_equal_to filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'not_equal_to', + values: ['support'], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 1 + expect(result[:contacts].first.id).to eq el_contact.id + end + + it 'returns is_present filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 2 + expect(result[:contacts].first.label_list).to include('support') + expect(result[:contacts].last.label_list).to include('support') + end + + it 'returns is_not_present filter results properly' do + params[:payload] = [ + { + attribute_key: 'labels', + filter_operator: 'is_not_present', + values: [], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be 1 + expect(result[:contacts].first.id).to eq el_contact.id + end + end + + context 'with standard attributes - last_activity_at' do + before do + Time.zone = 'UTC' + el_contact.update(last_activity_at: (Time.zone.today - 4.days)) + cs_contact.update(last_activity_at: (Time.zone.today - 5.days)) + en_contact.update(last_activity_at: (Time.zone.today - 2.days)) + end + + it 'filter by last_activity_at 3_days_before and custom_attributes' do + params[:payload] = [ + { + attribute_key: 'last_activity_at', + filter_operator: 'days_before', + values: [3], + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'contact_additional_information', + filter_operator: 'equal_to', + values: ['test custom data'], + query_operator: nil + }.with_indifferent_access + ] + + expected_count = Contact.where( + "last_activity_at < ? AND + custom_attributes->>'contact_additional_information' = ?", + (Time.zone.today - 3.days), + 'test custom data' + ).count + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be expected_count + expect(result[:contacts].first.id).to eq(el_contact.id) + end + + it 'filter by last_activity_at 2_days_before and custom_attributes' do + params[:payload] = [ + { + attribute_key: 'last_activity_at', + filter_operator: 'days_before', + values: [2], + query_operator: nil + }.with_indifferent_access + ] + + expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count + + result = filter_service.new(params, first_user).perform + expect(result[:contacts].length).to be expected_count + expect(result[:contacts].pluck(:id)).to include(el_contact.id) + expect(result[:contacts].pluck(:id)).to include(cs_contact.id) + expect(result[:contacts].pluck(:id)).not_to include(en_contact.id) + end + end + + context 'with additional attributes' do + let(:payload) do + [ + { + attribute_key: 'country_code', + filter_operator: 'equal_to', + values: ['uk'], + query_operator: nil + }.with_indifferent_access + ] + end + + it 'filter contacts by additional_attributes' do + params[:payload] = payload + result = filter_service.new(params, first_user).perform + expect(result[:count]).to be 1 + expect(result[:contacts].first.id).to eq(en_contact.id) + end + end + + context 'with custom attributes' do it 'filter by custom_attributes and labels' do params[:payload] = [ { @@ -181,9 +261,9 @@ describe Contacts::FilterService do query_operator: 'AND' }.with_indifferent_access, { - attribute_key: 'browser_language', + attribute_key: 'country_code', filter_operator: 'equal_to', - values: ['el'], + values: ['GR'], query_operator: 'AND' }.with_indifferent_access, { @@ -220,62 +300,6 @@ describe Contacts::FilterService do expect(result[:contacts].length).to be expected_count expect(result[:contacts].pluck(:id)).to include(el_contact.id) end - - context 'with x_days_before filter' do - before do - Time.zone = 'UTC' - el_contact.update(last_activity_at: (Time.zone.today - 4.days)) - cs_contact.update(last_activity_at: (Time.zone.today - 5.days)) - en_contact.update(last_activity_at: (Time.zone.today - 2.days)) - end - - it 'filter by last_activity_at 3_days_before and custom_attributes' do - params[:payload] = [ - { - attribute_key: 'last_activity_at', - filter_operator: 'days_before', - values: [3], - query_operator: 'AND' - }.with_indifferent_access, - { - attribute_key: 'contact_additional_information', - filter_operator: 'equal_to', - values: ['test custom data'], - query_operator: nil - }.with_indifferent_access - ] - - expected_count = Contact.where( - "last_activity_at < ? AND - custom_attributes->>'contact_additional_information' = ?", - (Time.zone.today - 3.days), - 'test custom data' - ).count - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be expected_count - expect(result[:contacts].first.id).to eq(el_contact.id) - end - - it 'filter by last_activity_at 2_days_before and custom_attributes' do - params[:payload] = [ - { - attribute_key: 'last_activity_at', - filter_operator: 'days_before', - values: [2], - query_operator: nil - }.with_indifferent_access - ] - - expected_count = Contact.where('last_activity_at < ?', (Time.zone.today - 2.days)).count - - result = filter_service.new(params, first_user).perform - expect(result[:contacts].length).to be expected_count - expect(result[:contacts].pluck(:id)).to include(el_contact.id) - expect(result[:contacts].pluck(:id)).to include(cs_contact.id) - expect(result[:contacts].pluck(:id)).not_to include(en_contact.id) - end - end end end end diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index cb2279acf..2fbaf5f61 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -55,7 +55,7 @@ describe Conversations::FilterService do [ { attribute_key: 'browser_language', - filter_operator: 'contains', + filter_operator: 'equal_to', values: 'en', query_operator: 'AND', custom_attribute_type: '' @@ -88,7 +88,7 @@ describe Conversations::FilterService do it 'filters items with contains filter_operator with values being an array' do params[:payload] = [{ attribute_key: 'browser_language', - filter_operator: 'contains', + filter_operator: 'equal_to', values: %w[tr fr], query_operator: '', custom_attribute_type: '' @@ -106,7 +106,7 @@ describe Conversations::FilterService do it 'filters items with does not contain filter operator with values being an array' do params[:payload] = [{ attribute_key: 'browser_language', - filter_operator: 'does_not_contain', + filter_operator: 'not_equal_to', values: %w[tr en], query_operator: '', custom_attribute_type: '' @@ -291,6 +291,11 @@ describe Conversations::FilterService do end it 'filter by custom_attributes and additional_attributes' do + conversations = user_1.conversations + conversations[0].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'silver' }) + conversations[1].update!(additional_attributes: { 'browser_language': 'en' }, custom_attributes: { conversation_type: 'platinum' }) + conversations[2].update!(additional_attributes: { 'browser_language': 'tr' }, custom_attributes: { conversation_type: 'platinum' }) + params[:payload] = [ { attribute_key: 'conversation_type', @@ -301,7 +306,7 @@ describe Conversations::FilterService do }.with_indifferent_access, { attribute_key: 'browser_language', - filter_operator: 'is_equal_to', + filter_operator: 'not_equal_to', values: 'en', query_operator: nil, custom_attribute_type: '' diff --git a/tailwind.config.js b/tailwind.config.js index aa7a2a14a..a34650fb5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,6 +26,11 @@ module.exports = { './app/views/**/*.html.erb', ], theme: { + extend: { + fontFamily: { + inter: ['Inter', ...defaultTheme.fontFamily.sans], + }, + }, fontSize: { ...defaultTheme.fontSize, xxs: '0.625rem', diff --git a/yarn.lock b/yarn.lock index 9e919e2f4..71040ce17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8140,13 +8140,13 @@ bn.js@^5.2.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== dependencies: bytes "3.1.2" - content-type "~1.0.4" + content-type "~1.0.5" debug "2.6.9" depd "2.0.0" destroy "1.2.0" @@ -8154,7 +8154,7 @@ body-parser@1.20.1: iconv-lite "0.4.24" on-finished "2.4.1" qs "6.11.0" - raw-body "2.5.1" + raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -9129,6 +9129,11 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" @@ -9146,10 +9151,10 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== copy-concurrently@^1.0.0: version "1.0.5" @@ -11054,16 +11059,16 @@ expect@^29.0.0, expect@^29.7.0: jest-util "^29.7.0" express@^4.17.1: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.1" + body-parser "1.20.2" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.5.0" + cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" @@ -11407,9 +11412,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0, follow-redirects@^1.15.0: - version "1.15.3" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" - integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== for-each@^0.3.3: version "0.3.3" @@ -14551,10 +14556,10 @@ markdown-it@^10.0.0: mdurl "^1.0.1" uc.micro "^1.0.5" -markdown-it@^13.0.1: - version "13.0.1" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.1.tgz#c6ecc431cacf1a5da531423fc6a42807814af430" - integrity sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q== +markdown-it@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-13.0.2.tgz#1bc22e23379a6952e5d56217fbed881e0c94d536" + integrity sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w== dependencies: argparse "^2.0.1" entities "~3.0.1" @@ -17578,10 +17583,10 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== dependencies: bytes "3.1.2" http-errors "2.0.0"