diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 2b138a3fb..5f4570474 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -24,6 +24,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.mentioned': this.onConversationMentioned, 'notification.created': this.onNotificationCreated, 'notification.deleted': this.onNotificationDeleted, + 'notification.updated': this.onNotificationUpdated, 'first.reply.created': this.onFirstReplyCreated, 'conversation.read': this.onConversationRead, 'conversation.updated': this.onConversationUpdated, @@ -200,6 +201,10 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('notifications/deleteNotification', data); }; + onNotificationUpdated = data => { + this.app.$store.dispatch('notifications/updateNotification', data); + }; + // eslint-disable-next-line class-methods-use-this onFirstReplyCreated = () => { bus.$emit('fetch_overview_reports'); diff --git a/app/javascript/dashboard/store/modules/notifications/actions.js b/app/javascript/dashboard/store/modules/notifications/actions.js index 6d6fdf79d..47455e465 100644 --- a/app/javascript/dashboard/store/modules/notifications/actions.js +++ b/app/javascript/dashboard/store/modules/notifications/actions.js @@ -54,7 +54,7 @@ export const actions = { try { await NotificationsAPI.read(primaryActorType, primaryActorId); commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1); - commit(types.UPDATE_NOTIFICATION, { id, read_at: new Date() }); + commit(types.READ_NOTIFICATION, { id, read_at: new Date() }); commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); } catch (error) { commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); @@ -64,7 +64,7 @@ export const actions = { commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }); try { await NotificationsAPI.unRead(id); - commit(types.UPDATE_NOTIFICATION, { id, read_at: null }); + commit(types.READ_NOTIFICATION, { id, read_at: null }); commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); } catch (error) { commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); @@ -127,6 +127,7 @@ export const actions = { id, snoozedUntil, }); + const { data: { snoozed_until = null }, } = response; @@ -140,6 +141,10 @@ export const actions = { } }, + updateNotification: async ({ commit }, data) => { + commit(types.UPDATE_NOTIFICATION, data); + }, + addNotification({ commit }, data) { commit(types.ADD_NOTIFICATION, data); }, diff --git a/app/javascript/dashboard/store/modules/notifications/mutations.js b/app/javascript/dashboard/store/modules/notifications/mutations.js index 4ad53c327..3493de279 100644 --- a/app/javascript/dashboard/store/modules/notifications/mutations.js +++ b/app/javascript/dashboard/store/modules/notifications/mutations.js @@ -34,7 +34,7 @@ export const mutations = { }); }); }, - [types.UPDATE_NOTIFICATION]: ($state, { id, read_at }) => { + [types.READ_NOTIFICATION]: ($state, { id, read_at }) => { Vue.set($state.records[id], 'read_at', read_at); }, [types.UPDATE_ALL_NOTIFICATIONS]: $state => { @@ -52,6 +52,15 @@ export const mutations = { Vue.set($state.meta, 'unreadCount', unreadCount); Vue.set($state.meta, 'count', count); }, + [types.UPDATE_NOTIFICATION]($state, data) { + const { notification, unread_count: unreadCount, count } = data; + Vue.set($state.records, notification.id, { + ...($state.records[notification.id] || {}), + ...notification, + }); + Vue.set($state.meta, 'unreadCount', unreadCount); + Vue.set($state.meta, 'count', count); + }, [types.DELETE_NOTIFICATION]($state, data) { const { notification, unread_count: unreadCount, count } = data; Vue.delete($state.records, notification.id); diff --git a/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js b/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js index 251f8f656..006c2e5af 100644 --- a/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js @@ -1,7 +1,6 @@ import axios from 'axios'; import { actions } from '../../notifications/actions'; import types from '../../../mutation-types'; - const commit = jest.fn(); global.axios = axios; jest.mock('axios'); @@ -101,7 +100,7 @@ describe('#actions', () => { expect(commit.mock.calls).toEqual([ [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }], [types.SET_NOTIFICATIONS_UNREAD_COUNT, 1], - [types.UPDATE_NOTIFICATION, { id: 1, read_at: expect.any(Date) }], + [types.READ_NOTIFICATION, { id: 1, read_at: expect.any(Date) }], [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }], ]); }); @@ -125,7 +124,7 @@ describe('#actions', () => { await actions.unread({ commit }, { id: 1 }); expect(commit.mock.calls).toEqual([ ['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: true }], - ['UPDATE_NOTIFICATION', { id: 1, read_at: null }], + ['READ_NOTIFICATION', { id: 1, read_at: null }], ['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: false }], ]); }); @@ -264,17 +263,20 @@ describe('#actions', () => { describe('snooze', () => { it('sends correct actions if API is success', async () => { - axios.post.mockResolvedValue({}); + axios.post.mockResolvedValue({ + data: { snoozed_until: '20 Jan, 5.04pm' }, + }); await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 }); expect(commit.mock.calls).toEqual([ [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }], - [types.SNOOZE_NOTIFICATION, { id: 1, snoozed_until: 1703057715 }], + [types.SNOOZE_NOTIFICATION, { id: 1, snoozed_until: '20 Jan, 5.04pm' }], [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }], ]); }); it('sends correct actions if API is error', async () => { axios.post.mockRejectedValue({ message: 'Incorrect header' }); await actions.snooze({ commit }, { id: 1, snoozedUntil: 1703057715 }); + expect(commit.mock.calls).toEqual([ [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }], [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }], diff --git a/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js index 35fa1050f..3d1fa00c5 100644 --- a/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js @@ -75,7 +75,7 @@ describe('#mutations', () => { 1: { id: 1, primary_actor_id: 1 }, }, }; - mutations[types.UPDATE_NOTIFICATION](state, { + mutations[types.READ_NOTIFICATION](state, { id: 1, read_at: true, }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 7f625063d..0d4f0c010 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -132,6 +132,7 @@ export default { SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT', SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG', UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION', + READ_NOTIFICATION: 'READ_NOTIFICATION', ADD_NOTIFICATION: 'ADD_NOTIFICATION', DELETE_NOTIFICATION: 'DELETE_NOTIFICATION', UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS', diff --git a/app/jobs/trigger_scheduled_items_job.rb b/app/jobs/trigger_scheduled_items_job.rb index 82634e4d5..15b0011c8 100644 --- a/app/jobs/trigger_scheduled_items_job.rb +++ b/app/jobs/trigger_scheduled_items_job.rb @@ -24,3 +24,5 @@ class TriggerScheduledItemsJob < ApplicationJob Notification::RemoveOldNotificationJob.perform_later end end + +TriggerScheduledItemsJob.prepend_mod_with('TriggerScheduledItemsJob') diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index 3d6b4a48c..5aac56db3 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -7,6 +7,12 @@ class ActionCableListener < BaseListener broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count }) end + def notification_updated(event) + notification, account, unread_count, count = extract_notification_and_account(event) + tokens = [event.data[:notification].user.pubsub_token] + broadcast(account, tokens, NOTIFICATION_UPDATED, { notification: notification.push_event_data, unread_count: unread_count, count: count }) + end + def notification_deleted(event) return if event.data[:notification].user.blank? diff --git a/app/listeners/notification_listener.rb b/app/listeners/notification_listener.rb index 39303450d..666bc4d13 100644 --- a/app/listeners/notification_listener.rb +++ b/app/listeners/notification_listener.rb @@ -1,4 +1,18 @@ class NotificationListener < BaseListener + def conversation_bot_handoff(event) + conversation, account = extract_conversation_and_account(event) + return if conversation.pending? + + conversation.inbox.members.each do |agent| + NotificationBuilder.new( + notification_type: 'conversation_creation', + user: agent, + account: account, + primary_actor: conversation + ).perform + end + end + def conversation_created(event) conversation, account = extract_conversation_and_account(event) return if conversation.pending? diff --git a/app/models/notification.rb b/app/models/notification.rb index 21222079b..e0b31b457 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -46,6 +46,7 @@ class Notification < ApplicationRecord before_create :set_last_activity_at after_create_commit :process_notification_delivery, :dispatch_create_event after_destroy_commit :dispatch_destroy_event + after_update_commit :dispatch_update_event PRIMARY_ACTORS = ['Conversation'].freeze @@ -75,7 +76,8 @@ class Notification < ApplicationRecord def primary_actor_data { id: primary_actor.push_event_data[:id], - meta: primary_actor.push_event_data[:meta] + meta: primary_actor.push_event_data[:meta], + inbox_id: primary_actor.push_event_data[:inbox_id] } end @@ -142,6 +144,10 @@ class Notification < ApplicationRecord Rails.configuration.dispatcher.dispatch(NOTIFICATION_CREATED, Time.zone.now, notification: self) end + def dispatch_update_event + Rails.configuration.dispatcher.dispatch(NOTIFICATION_UPDATED, Time.zone.now, notification: self) + end + def dispatch_destroy_event Rails.configuration.dispatcher.dispatch(NOTIFICATION_DELETED, Time.zone.now, notification: self) end diff --git a/db/migrate/20240207103014_change_applied_sla_sla_status_to_enum.rb b/db/migrate/20240207103014_change_applied_sla_sla_status_to_enum.rb new file mode 100644 index 000000000..90d33a370 --- /dev/null +++ b/db/migrate/20240207103014_change_applied_sla_sla_status_to_enum.rb @@ -0,0 +1,6 @@ +class ChangeAppliedSlaSlaStatusToEnum < ActiveRecord::Migration[7.0] + def change + remove_column :applied_slas, :sla_status, :string + add_column :applied_slas, :sla_status, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 3439a33da..7d2372554 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_01_31_040316) do +ActiveRecord::Schema[7.0].define(version: 2024_02_07_103014) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -119,9 +119,9 @@ ActiveRecord::Schema[7.0].define(version: 2024_01_31_040316) do t.bigint "account_id", null: false t.bigint "sla_policy_id", null: false t.bigint "conversation_id", null: false - t.string "sla_status" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "sla_status", default: 0 t.index ["account_id"], name: "index_applied_slas_on_account_id" t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id" t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id" diff --git a/enterprise/app/jobs/enterprise/trigger_scheduled_items_job.rb b/enterprise/app/jobs/enterprise/trigger_scheduled_items_job.rb new file mode 100644 index 000000000..452a89a92 --- /dev/null +++ b/enterprise/app/jobs/enterprise/trigger_scheduled_items_job.rb @@ -0,0 +1,11 @@ +module Enterprise::TriggerScheduledItemsJob + def perform + super + + ## Triggers Enterprise specific jobs + #################################### + + # Triggers Account Sla jobs + Sla::TriggerSlasForAccountsJob.perform_later + 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 new file mode 100644 index 000000000..153749267 --- /dev/null +++ b/enterprise/app/jobs/sla/process_account_applied_slas_job.rb @@ -0,0 +1,9 @@ +class Sla::ProcessAccountAppliedSlasJob < ApplicationJob + queue_as :medium + + def perform(account) + account.applied_slas.where(sla_status: 'active').each do |applied_sla| + Sla::ProcessAppliedSlaJob.perform_later(applied_sla) + end + end +end diff --git a/enterprise/app/jobs/sla/process_applied_sla_job.rb b/enterprise/app/jobs/sla/process_applied_sla_job.rb new file mode 100644 index 000000000..10fc22c31 --- /dev/null +++ b/enterprise/app/jobs/sla/process_applied_sla_job.rb @@ -0,0 +1,7 @@ +class Sla::ProcessAppliedSlaJob < ApplicationJob + queue_as :medium + + def perform(applied_sla) + Sla::EvaluateAppliedSlaService.new(applied_sla: applied_sla).perform + end +end diff --git a/enterprise/app/jobs/sla/trigger_slas_for_accounts_job.rb b/enterprise/app/jobs/sla/trigger_slas_for_accounts_job.rb new file mode 100644 index 000000000..212c44d94 --- /dev/null +++ b/enterprise/app/jobs/sla/trigger_slas_for_accounts_job.rb @@ -0,0 +1,10 @@ +class Sla::TriggerSlasForAccountsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + Account.find_each do |account| + Rails.logger.info "Enqueuing ProcessAccountAppliedSlasJob for account #{account.id}" + Sla::ProcessAccountAppliedSlasJob.perform_later(account) + end + end +end diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb index 8329f643b..825c747fc 100644 --- a/enterprise/app/models/applied_sla.rb +++ b/enterprise/app/models/applied_sla.rb @@ -3,7 +3,7 @@ # Table name: applied_slas # # id :bigint not null, primary key -# sla_status :string +# sla_status :integer default("active") # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null @@ -20,4 +20,6 @@ class AppliedSla < ApplicationRecord belongs_to :account belongs_to :sla_policy belongs_to :conversation + + enum sla_status: { active: 0, hit: 1, missed: 2 } end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 699e5b813..b4f9d189f 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -3,6 +3,7 @@ module Enterprise::Concerns::Account included do has_many :sla_policies, dependent: :destroy_async + has_many :applied_slas, dependent: :destroy_async def self.add_response_related_associations has_many :response_sources, dependent: :destroy_async diff --git a/enterprise/app/services/sla/evaluate_applied_sla_service.rb b/enterprise/app/services/sla/evaluate_applied_sla_service.rb new file mode 100644 index 000000000..817a854c7 --- /dev/null +++ b/enterprise/app/services/sla/evaluate_applied_sla_service.rb @@ -0,0 +1,78 @@ +class Sla::EvaluateAppliedSlaService + pattr_initialize [:applied_sla!] + + def perform + check_sla_thresholds + + # 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? + end + + private + + def check_sla_thresholds + [:first_response_time_threshold, :next_response_time_threshold, :resolution_time_threshold].each do |threshold| + next if applied_sla.sla_policy.send(threshold).blank? + + send("check_#{threshold}", applied_sla, applied_sla.conversation, applied_sla.sla_policy) + end + end + + def still_within_threshold?(threshold) + Time.zone.now.to_i < threshold + end + + def check_first_response_time_threshold(applied_sla, conversation, sla_policy) + threshold = conversation.created_at.to_i + sla_policy.first_response_time_threshold.to_i + return if first_reply_was_within_threshold?(conversation, threshold) + return if still_within_threshold?(threshold) + + handle_missed_sla(applied_sla) + end + + def first_reply_was_within_threshold?(conversation, threshold) + conversation.first_reply_created_at.present? && conversation.first_reply_created_at.to_i <= threshold + end + + def check_next_response_time_threshold(applied_sla, conversation, sla_policy) + # still waiting for first reply, so covered under first response time threshold + return if conversation.first_reply_created_at.blank? + # Waiting on customer response, no need to check next response time threshold + return if conversation.waiting_since.blank? + + threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i + return if still_within_threshold?(threshold) + + handle_missed_sla(applied_sla) + end + + def check_resolution_time_threshold(applied_sla, conversation, sla_policy) + return if conversation.resolved? + + threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i + return if still_within_threshold?(threshold) + + handle_missed_sla(applied_sla) + end + + def handle_missed_sla(applied_sla) + return unless applied_sla.active? + + applied_sla.update!(sla_status: 'missed') + Rails.logger.warn "SLA missed for conversation #{applied_sla.conversation.id} " \ + "in account #{applied_sla.account_id} " \ + "for sla_policy #{applied_sla.sla_policy.id}" + 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 +end diff --git a/lib/events/types.rb b/lib/events/types.rb index 6e34fc358..ff7eaf778 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -49,6 +49,7 @@ module Events::Types # notification events NOTIFICATION_CREATED = 'notification.created' NOTIFICATION_DELETED = 'notification.deleted' + NOTIFICATION_UPDATED = 'notification.updated' # agent events AGENT_ADDED = 'agent.added' diff --git a/spec/enterprise/jobs/enterprise/trigger_scheduled_items_job_spec.rb b/spec/enterprise/jobs/enterprise/trigger_scheduled_items_job_spec.rb new file mode 100644 index 000000000..d6c84104a --- /dev/null +++ b/spec/enterprise/jobs/enterprise/trigger_scheduled_items_job_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe TriggerScheduledItemsJob do + subject(:job) { described_class.perform_later } + + it 'triggers Sla::TriggerSlasForAccountsJob' do + expect(Sla::TriggerSlasForAccountsJob).to receive(:perform_later).once + described_class.perform_now + 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 new file mode 100644 index 000000000..beae967db --- /dev/null +++ b/spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe Sla::ProcessAccountAppliedSlasJob do + context 'when perform is called' do + let!(:account) { create(:account) } + let!(:sla_policy) { create(:sla_policy, first_response_time_threshold: 1.hour) } + 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') } + + 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 + 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 + 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) + end + end +end diff --git a/spec/enterprise/jobs/sla/process_applied_sla_job_spec.rb b/spec/enterprise/jobs/sla/process_applied_sla_job_spec.rb new file mode 100644 index 000000000..fd56e0df3 --- /dev/null +++ b/spec/enterprise/jobs/sla/process_applied_sla_job_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe Sla::ProcessAppliedSlaJob do + context 'when perform is called' do + let(:account) { create(:account) } + + it 'enqueues the job' do + expect { described_class.perform_later }.to have_enqueued_job(described_class) + .on_queue('medium') + end + + it 'calls the EvaluateAppliedSlaService' do + applied_sla = create(:applied_sla) + expect(Sla::EvaluateAppliedSlaService).to receive(:new).with(applied_sla: applied_sla).and_call_original + described_class.perform_now(applied_sla) + end + end +end diff --git a/spec/enterprise/jobs/sla/trigger_slas_for_accounts_job_spec.rb b/spec/enterprise/jobs/sla/trigger_slas_for_accounts_job_spec.rb new file mode 100644 index 000000000..95650748a --- /dev/null +++ b/spec/enterprise/jobs/sla/trigger_slas_for_accounts_job_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe Sla::TriggerSlasForAccountsJob do + context 'when perform is called' do + let(:account) { create(:account) } + + it 'enqueues the job' do + expect { described_class.perform_later }.to have_enqueued_job(described_class) + .on_queue('scheduled_jobs') + end + + it 'calls the ProcessAccountAppliedSlasJob' do + expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account).and_call_original + described_class.perform_now + 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 new file mode 100644 index 000000000..0f82715c0 --- /dev/null +++ b/spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe Sla::EvaluateAppliedSlaService do + let!(:conversation) { create(:conversation, created_at: 6.hours.ago) } + let!(:sla_policy) do + create(:sla_policy, account: conversation.account, + first_response_time_threshold: nil, + next_response_time_threshold: nil, + resolution_time_threshold: nil) + end + let!(:applied_sla) { create(:applied_sla, conversation: conversation, sla_policy: sla_policy, sla_status: 'active') } + + describe '#perform - SLA misses' do + context 'when first response SLA is missed' do + before { sla_policy.update(first_response_time_threshold: 1.hour) } + + 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 " \ + "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") + expect(applied_sla.reload.sla_status).to eq('missed') + end + end + + context 'when next response SLA is missed' do + before do + sla_policy.update(next_response_time_threshold: 1.hour) + 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 + 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 " \ + "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") + expect(applied_sla.reload.sla_status).to eq('missed') + end + end + + context 'when resolution time SLA is missed' do + before { sla_policy.update(resolution_time_threshold: 1.hour) } + + 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 " \ + "#{applied_sla.account_id} for sla_policy #{sla_policy.id}") + expect(applied_sla.reload.sla_status).to eq('missed') + end + end + + # We will mark resolved miss only if while processing the SLA + # if the conversation is resolved and the resolution time is missed by small margins then we will not mark it as missed + context 'when resolved conversation with resolution time SLA is missed' do + before do + conversation.resolved! + sla_policy.update(resolution_time_threshold: 1.hour) + end + + it 'does not update the SLA status to missed' do + described_class.new(applied_sla: applied_sla).perform + expect(applied_sla.reload.sla_status).to eq('hit') + end + end + + context 'when multiple SLAs are missed' do + before do + sla_policy.update(first_response_time_threshold: 1.hour, next_response_time_threshold: 1.hour, resolution_time_threshold: 1.hour) + 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 + 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 " \ + "#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time + expect(applied_sla.reload.sla_status).to eq('missed') + end + end + end + + describe '#perform - SLA hits' do + context 'when first response SLA is hit' do + before do + sla_policy.update(first_response_time_threshold: 6.hours) + conversation.update(first_reply_created_at: 30.minutes.ago) + end + + it 'sla remains active until conversation is resolved' do + described_class.new(applied_sla: applied_sla).perform + expect(applied_sla.reload.sla_status).to eq('active') + end + + it 'updates the SLA status to hit and logs an info when conversations is resolved' do + conversation.resolved! + allow(Rails.logger).to receive(:info) + described_class.new(applied_sla: applied_sla).perform + 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') + end + end + + context 'when next response SLA is hit' do + before do + sla_policy.update(next_response_time_threshold: 6.hours) + conversation.update(first_reply_created_at: 30.minutes.ago, waiting_since: nil) + end + + it 'sla remains active until conversation is resolved' do + described_class.new(applied_sla: applied_sla).perform + expect(applied_sla.reload.sla_status).to eq('active') + end + + it 'updates the SLA status to hit and logs an info when conversations is resolved' do + conversation.resolved! + allow(Rails.logger).to receive(:info) + described_class.new(applied_sla: applied_sla).perform + 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') + end + end + + context 'when resolution time SLA is hit' do + before do + sla_policy.update(resolution_time_threshold: 8.hours) + conversation.resolved! + end + + it 'updates the SLA status to hit and logs an info' do + allow(Rails.logger).to receive(:info) + described_class.new(applied_sla: applied_sla).perform + 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') + end + end + end +end diff --git a/spec/factories/applied_slas.rb b/spec/factories/applied_slas.rb index 8ab48c558..f99fa4134 100644 --- a/spec/factories/applied_slas.rb +++ b/spec/factories/applied_slas.rb @@ -1,8 +1,11 @@ FactoryBot.define do factory :applied_sla do - account sla_policy conversation sla_status { 'active' } + + after(:build) do |applied_sla| + applied_sla.account ||= applied_sla.conversation&.account || create(:account) + end end end diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb index c3f34dfeb..ec1f72015 100644 --- a/spec/jobs/trigger_scheduled_items_job_spec.rb +++ b/spec/jobs/trigger_scheduled_items_job_spec.rb @@ -10,6 +10,31 @@ RSpec.describe TriggerScheduledItemsJob do .on_queue('scheduled_jobs') end + it 'triggers Conversations::ReopenSnoozedConversationsJob' do + expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once + described_class.perform_now + end + + it 'triggers Notification::ReopenSnoozedNotificationsJob' do + expect(Notification::ReopenSnoozedNotificationsJob).to receive(:perform_later).once + described_class.perform_now + end + + it 'triggers Account::ConversationsResolutionSchedulerJob' do + expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once + described_class.perform_now + end + + it 'triggers Channels::Whatsapp::TemplatesSyncSchedulerJob' do + expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once + described_class.perform_now + end + + it 'triggers Notification::RemoveOldNotificationJob' do + expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once + described_class.perform_now + end + context 'when unexecuted Scheduled campaign jobs' do let!(:twilio_sms) { create(:channel_twilio_sms) } let!(:twilio_inbox) { create(:inbox, channel: twilio_sms) } @@ -20,30 +45,5 @@ RSpec.describe TriggerScheduledItemsJob do expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once described_class.perform_now end - - it 'triggers Conversations::ReopenSnoozedConversationsJob' do - expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once - described_class.perform_now - end - - it 'triggers Notification::ReopenSnoozedNotificationsJob' do - expect(Notification::ReopenSnoozedNotificationsJob).to receive(:perform_later).once - described_class.perform_now - end - - it 'triggers Account::ConversationsResolutionSchedulerJob' do - expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once - described_class.perform_now - end - - it 'triggers Channels::Whatsapp::TemplatesSyncSchedulerJob' do - expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once - described_class.perform_now - end - - it 'triggers Notification::RemoveOldNotificationJob' do - expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once - described_class.perform_now - end end end diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index 6275c8dc3..5802ce00a 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -152,6 +152,27 @@ describe ActionCableListener do end end + describe '#notification_updated' do + let(:event_name) { :'notification.updated' } + let!(:notification) { create(:notification, account: account, user: agent) } + let!(:event) { Events::Base.new(event_name, Time.zone.now, notification: notification) } + + it 'sends notification to account admins, inbox agents' do + expect(ActionCableBroadcastJob).to receive(:perform_later).with( + [agent.pubsub_token], + 'notification.updated', + { + account_id: notification.account_id, + notification: notification.push_event_data, + unread_count: 1, + count: 1 + } + ) + + listener.notification_updated(event) + end + end + describe '#conversation_updated' do let(:event_name) { :'conversation.updated' } let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation, user: agent, is_private: false) } diff --git a/spec/listeners/notification_listener_spec.rb b/spec/listeners/notification_listener_spec.rb index 4eb82d6f0..1505b0f57 100644 --- a/spec/listeners/notification_listener_spec.rb +++ b/spec/listeners/notification_listener_spec.rb @@ -119,4 +119,40 @@ describe NotificationListener do end end end + + describe 'conversation_bot_handoff' do + let(:event_name) { :'conversation.bot_handoff' } + + context 'when conversation is bot handoff' do + it 'creates notifications for inbox members who have notifications turned on' do + notification_setting = first_agent.notification_settings.first + notification_setting.selected_email_flags = [:email_conversation_creation] + notification_setting.selected_push_flags = [] + notification_setting.save! + + create(:inbox_member, user: first_agent, inbox: inbox) + conversation.reload + + event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) + + listener.conversation_bot_handoff(event) + expect(notification_setting.user.notifications.count).to eq(1) + end + + it 'does not create notification for inbox members who have notifications turned off' do + notification_setting = agent_with_out_notification.notification_settings.first + notification_setting.unselect_all_email_flags + notification_setting.unselect_all_push_flags + notification_setting.save! + + create(:inbox_member, user: agent_with_out_notification, inbox: inbox) + conversation.reload + + event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) + + listener.conversation_bot_handoff(event) + expect(notification_setting.user.notifications.count).to eq(0) + end + end + end end