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 @@
+
+
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 @@ + + +
+
+
- {{ $t('SLA.LIST.404') }} -
-+ {{ $t('SLA.LIST.404') }} +
+| - {{ thHeader }} - | - - -||||||
|---|---|---|---|---|---|---|
| - - {{ sla.name }} - - | -{{ sla.description }} | -- - {{ displayTime(sla.first_response_time_threshold) }} - - | -- - {{ displayTime(sla.next_response_time_threshold) }} - - | -- - {{ displayTime(sla.resolution_time_threshold) }} - - | -- - {{ sla.only_during_business_hours }} - - | -
- |
-
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 }}. +
+ + 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"