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/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
index d2d51baef..ebf8e49dd 100644
--- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
+++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
@@ -2,13 +2,9 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
before_action :authorize_request
def create
- ActiveRecord::Base.transaction do
- authenticate_twilio
- build_inbox
- setup_webhooks if @twilio_channel.sms?
- rescue StandardError => e
- render_could_not_create_error(e.message)
- end
+ process_create
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
end
private
@@ -17,6 +13,14 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
authorize ::Inbox
end
+ def process_create
+ ActiveRecord::Base.transaction do
+ authenticate_twilio
+ build_inbox
+ setup_webhooks if @twilio_channel.sms?
+ end
+ end
+
def authenticate_twilio
client = if permitted_params[:api_key_sid].present?
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d2960a699..2f389049d 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -25,3 +25,4 @@ class ApplicationController < ActionController::Base
}
end
end
+ApplicationController.include_mod_with('Concerns::ApplicationControllerConcern')
diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb
index f3c85be7a..44592c201 100644
--- a/app/finders/conversation_finder.rb
+++ b/app/finders/conversation_finder.rb
@@ -163,10 +163,14 @@ class ConversationFinder
params[:page] || 1
end
- def conversations
- @conversations = @conversations.includes(
+ def conversations_base_query
+ @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
+ end
+
+ def conversations
+ @conversations = conversations_base_query
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations = @conversations.send(sort_by, sort_order)
@@ -178,3 +182,4 @@ class ConversationFinder
end
end
end
+ConversationFinder.prepend_mod_with('ConversationFinder')
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": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue index 6dd3c306b..618805309 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -1,6 +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/config/locales/en.yml b/config/locales/en.yml index 4c4f0483c..92c56574d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -106,6 +106,15 @@ en: avg_resolution_time: Avg resolution time conversation_traffic_csv: timezone: Timezone + sla_csv: + conversation_id: Conversation ID + sla_policy_breached: SLA Policy + assignee: Assignee + team: Team + inbox: Inbox + labels: Labels + conversation_link: Link to the Conversation + breached_events: Breached Events default_group_by: day csat: headers: 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/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 498ad13d3..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_19_062553) do +ActiveRecord::Schema[7.0].define(version: 2024_03_22_071629) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -472,7 +472,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_19_062553) do t.integer "priority" t.bigint "sla_policy_id" t.datetime "waiting_since" - t.string "cached_label_list" + t.text "cached_label_list" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" 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/controllers/api/v1/accounts/enterprise_accounts_controller.rb b/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb index f3028721c..32182d798 100644 --- a/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/enterprise_accounts_controller.rb @@ -1,8 +1,2 @@ class Api::V1::Accounts::EnterpriseAccountsController < Api::V1::Accounts::BaseController - before_action :prepend_view_paths - - # Prepend the view path to the enterprise/app/views won't be available by default - def prepend_view_paths - prepend_view_path 'enterprise/app/views/' - end end diff --git a/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb new file mode 100644 index 000000000..a44a0eceb --- /dev/null +++ b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb @@ -0,0 +1,12 @@ +module Enterprise::Concerns::ApplicationControllerConcern + extend ActiveSupport::Concern + + included do + before_action :prepend_view_paths + end + + # Prepend the view path to the enterprise/app/views won't be available by default + def prepend_view_paths + prepend_view_path 'enterprise/app/views/' + end +end 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/finders/enterprise/conversation_finder.rb b/enterprise/app/finders/enterprise/conversation_finder.rb new file mode 100644 index 000000000..24e6b493d --- /dev/null +++ b/enterprise/app/finders/enterprise/conversation_finder.rb @@ -0,0 +1,5 @@ +module Enterprise::ConversationFinder + def conversations_base_query + current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super + end +end 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 c2dde8377..8207b2cc0 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -27,7 +27,35 @@ class AppliedSla < ApplicationRecord validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] } before_validation :ensure_account_id - enum sla_status: { active: 0, hit: 1, missed: 2 } + enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 } + + scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? } + scope :filter_by_inbox_id, ->(inbox_id) { where(inbox_id: inbox_id) if inbox_id.present? } + scope :filter_by_team_id, ->(team_id) { where(team_id: team_id) if team_id.present? } + scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? } + scope :filter_by_label_list, ->(label_list) { joins(:conversation).where(conversations: { cached_label_list: label_list }) if label_list.present? } + scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id| + if assigned_agent_id.present? + joins(:conversation).where(conversations: { assigned_agent_id: assigned_agent_id }) + end + } + scope :missed, -> { where(sla_status: :missed) } + + def push_event_data + { + id: id, + sla_id: sla_policy_id, + sla_status: sla_status, + created_at: created_at.to_i, + updated_at: updated_at.to_i, + sla_description: sla_policy.description, + sla_name: sla_policy.name, + sla_first_response_time_threshold: sla_policy.first_response_time_threshold, + sla_next_response_time_threshold: sla_policy.next_response_time_threshold, + sla_only_during_business_hours: sla_policy.only_during_business_hours, + sla_resolution_time_threshold: sla_policy.resolution_time_threshold + } + end private 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/enterprise/enterprise_conversation_concern.rb b/enterprise/app/models/enterprise/concerns/conversation.rb similarity index 88% rename from enterprise/app/models/enterprise/enterprise_conversation_concern.rb rename to enterprise/app/models/enterprise/concerns/conversation.rb index 721f069dc..f03d643f4 100644 --- a/enterprise/app/models/enterprise/enterprise_conversation_concern.rb +++ b/enterprise/app/models/enterprise/concerns/conversation.rb @@ -1,9 +1,10 @@ -module Enterprise::EnterpriseConversationConcern +module Enterprise::Concerns::Conversation extend ActiveSupport::Concern included do belongs_to :sla_policy, optional: true - has_one :applied_sla, dependent: :destroy + has_one :applied_sla, dependent: :destroy_async + has_many :sla_events, dependent: :destroy_async before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? } end diff --git a/enterprise/app/models/sla_event.rb b/enterprise/app/models/sla_event.rb index 28f709346..1ba18f01a 100644 --- a/enterprise/app/models/sla_event.rb +++ b/enterprise/app/models/sla_event.rb @@ -31,6 +31,17 @@ class SlaEvent < ApplicationRecord enum event_type: { frt: 0, nrt: 1, rt: 2 } before_validation :ensure_applied_sla_id, :ensure_account_id, :ensure_inbox_id, :ensure_sla_policy_id + after_create_commit :create_notifications + + def push_event_data + { + id: id, + event_type: event_type, + meta: meta, + created_at: created_at.to_i, + updated_at: updated_at.to_i + } + end private @@ -49,4 +60,28 @@ class SlaEvent < ApplicationRecord def ensure_sla_policy_id self.sla_policy_id ||= applied_sla&.sla_policy_id end + + def create_notifications + notify_users = conversation.conversation_participants.map(&:user) + # Add all admins from the account to notify list + notify_users += account.administrators + # Ensure conversation assignee is notified + notify_users += [conversation.assignee] if conversation.assignee.present? + + notification_type = { + 'frt' => 'sla_missed_first_response', + 'nrt' => 'sla_missed_next_response', + 'rt' => 'sla_missed_resolution' + }[event_type] + + notify_users.uniq.each do |user| + NotificationBuilder.new( + notification_type: notification_type, + user: user, + account: account, + primary_actor: conversation, + secondary_actor: sla_policy + ).perform + end + end end diff --git a/enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb b/enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb new file mode 100644 index 000000000..686b05dc2 --- /dev/null +++ b/enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb @@ -0,0 +1,12 @@ +module Enterprise::Conversations::EventDataPresenter + def push_data + if account.feature_enabled?('sla') + super.merge( + applied_sla: applied_sla&.push_event_data, + sla_events: sla_events.map(&:push_event_data) + ) + else + super + end + end +end 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/_applied_sla.json.jbuilder b/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder new file mode 100644 index 000000000..e7f1c49fc --- /dev/null +++ b/enterprise/app/views/api/v1/models/_applied_sla.json.jbuilder @@ -0,0 +1,11 @@ +json.id resource.id +json.sla_id resource.sla_policy_id +json.sla_status resource.sla_status +json.created_at resource.created_at.to_i +json.updated_at resource.updated_at.to_i +json.sla_description resource.sla_policy.description +json.sla_name resource.sla_policy.name +json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold +json.sla_next_response_time_threshold resource.sla_policy.next_response_time_threshold +json.sla_only_during_business_hours resource.sla_policy.only_during_business_hours +json.sla_resolution_time_threshold resource.sla_policy.resolution_time_threshold 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/enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder b/enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder new file mode 100644 index 000000000..5a390a68b --- /dev/null +++ b/enterprise/app/views/enterprise/api/v1/conversations/partials/_conversation.json.jbuilder @@ -0,0 +1,10 @@ +if conversation.account.feature_enabled?('sla') + json.applied_sla do + json.partial! 'api/v1/models/applied_sla', formats: [:json], resource: conversation.applied_sla if conversation.applied_sla.present? + end + json.sla_events do + json.array! conversation.sla_events do |sla_event| + json.partial! 'api/v1/models/sla_event', formats: [:json], sla_event: sla_event + end + end +end diff --git a/package.json b/package.json index fcf7e23fa..313821424 100644 --- a/package.json +++ b/package.json @@ -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/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/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb new file mode 100644 index 000000000..4d5269cde --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe 'Conversations API', type: :request do + let(:account) { create(:account) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + describe 'GET /api/v1/accounts/{account.id}/conversations/:id' do + it 'returns SLA data for the conversation if the feature is enabled' do + account.enable_features!('sla') + conversation = create(:conversation, account: account) + applied_sla = create(:applied_sla, conversation: conversation) + sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla) + + get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id) + expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id) + end + + it 'does not return SLA data for the conversation if the feature is disabled' do + account.disable_features!('sla') + conversation = create(:conversation, account: account) + create(:applied_sla, conversation: conversation) + create(:sla_event, conversation: conversation) + + get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.keys).not_to include('applied_sla') + expect(response.parsed_body.keys).not_to include('sla_events') + end + end +end 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/applied_sla_spec.rb b/spec/enterprise/models/applied_sla_spec.rb index af73395a6..a1433f6a2 100644 --- a/spec/enterprise/models/applied_sla_spec.rb +++ b/spec/enterprise/models/applied_sla_spec.rb @@ -7,6 +7,27 @@ RSpec.describe AppliedSla, type: :model do it { is_expected.to belong_to(:conversation) } end + describe 'push_event_data' do + it 'returns the correct hash' do + applied_sla = create(:applied_sla) + expect(applied_sla.push_event_data).to eq( + { + id: applied_sla.id, + sla_id: applied_sla.sla_policy_id, + sla_status: applied_sla.sla_status, + created_at: applied_sla.created_at.to_i, + updated_at: applied_sla.updated_at.to_i, + sla_description: applied_sla.sla_policy.description, + sla_name: applied_sla.sla_policy.name, + sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold, + sla_next_response_time_threshold: applied_sla.sla_policy.next_response_time_threshold, + sla_only_during_business_hours: applied_sla.sla_policy.only_during_business_hours, + sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold + } + ) + end + end + describe 'validates_factory' do it 'creates valid applied sla policy object' do applied_sla = create(:applied_sla) diff --git a/spec/enterprise/models/sla_event_spec.rb b/spec/enterprise/models/sla_event_spec.rb index 862b22e6c..8a839d626 100644 --- a/spec/enterprise/models/sla_event_spec.rb +++ b/spec/enterprise/models/sla_event_spec.rb @@ -9,6 +9,21 @@ RSpec.describe SlaEvent, type: :model do it { is_expected.to belong_to(:inbox) } end + describe 'push_event_data' do + it 'returns the correct hash' do + sla_event = create(:sla_event) + expect(sla_event.push_event_data).to eq( + { + id: sla_event.id, + event_type: 'frt', + meta: sla_event.meta, + created_at: sla_event.created_at.to_i, + updated_at: sla_event.updated_at.to_i + } + ) + end + end + describe 'validates_factory' do it 'creates valid sla event object' do sla_event = create(:sla_event) @@ -25,4 +40,37 @@ RSpec.describe SlaEvent, type: :model do expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id end end + + describe 'create notifications' do + # create account, user and inbox + let!(:account) { create(:account) } + let!(:assignee) { create(:user, account: account) } + let!(:participant) { create(:user, account: account) } + let!(:admin) { create(:user, account: account, role: :administrator) } + let!(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, inbox: inbox, assignee: assignee, account: account) } + let(:sla_policy) { create(:sla_policy, account: conversation.account) } + let(:sla_event) { create(:sla_event, event_type: 'frt', conversation: conversation, sla_policy: sla_policy) } + + before do + # to ensure notifications are not sent to other users + create(:user, account: account) + create(:inbox_member, inbox: inbox, user: participant) + create(:conversation_participant, conversation: conversation, user: participant) + end + + it 'creates notifications for conversation participants, admins, and assignee' do + sla_event + + expect(Notification.count).to eq(3) + # check if notification type is sla_missed_first_response + expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(3) + # Check if notification is created for the assignee + expect(Notification.where(user_id: assignee.id).count).to eq(1) + # Check if notification is created for the account admin + expect(Notification.where(user_id: admin.id).count).to eq(1) + # Check if notification is created for participant + expect(Notification.where(user_id: participant.id).count).to eq(1) + end + end end diff --git a/spec/enterprise/presenters/conversations/event_data_presenter_spec.rb b/spec/enterprise/presenters/conversations/event_data_presenter_spec.rb new file mode 100644 index 000000000..f9897c96d --- /dev/null +++ b/spec/enterprise/presenters/conversations/event_data_presenter_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Conversations::EventDataPresenter do + let!(:presenter) { described_class.new(conversation) } + let!(:conversation) { create(:conversation) } + let!(:applied_sla) { create(:applied_sla, conversation: conversation) } + let!(:sla_event) { create(:sla_event, conversation: conversation, applied_sla: applied_sla) } + + describe '#push_data' do + it 'returns push event payload with applied sla & sla events if the feature is enabled' do + conversation.account.enable_features!('sla') + + expect(presenter.push_data).to include( + { + applied_sla: applied_sla.push_event_data, + sla_events: [sla_event.push_event_data] + } + ) + end + + it 'returns push event payload without applied sla & sla events if the feature is disabled' do + conversation.account.disable_features!('sla') + + expect(presenter.push_data).not_to include(:applied_sla, :sla_events) + end + end +end 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/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/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index 557d2b95c..f6a400b96 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -38,7 +38,8 @@ RSpec.describe Conversations::EventDataPresenter do end it 'returns push event payload' do - expect(presenter.push_data).to eq(expected_data) + # the exceptions are the values that would be added in enterprise edition. + expect(presenter.push_data.except(:applied_sla, :sla_events)).to include(expected_data) end end end 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"