From 873094175b804acae7d33cc5f7a46d855390f483 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Thu, 28 Aug 2025 20:39:30 +0700 Subject: [PATCH] add exclusion_rules support --- .../app/models/enterprise/concerns/inbox.rb | 1 + .../auto_assignment/assignment_service.rb | 36 ++++ .../assignment_service_spec.rb | 181 ++++++++++++++++++ .../agent_assignment_service_spec.rb | 2 + 4 files changed, 220 insertions(+) create mode 100644 spec/enterprise/auto_assignment/assignment_service_spec.rb diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb index 6f450bed1..0de61db23 100644 --- a/enterprise/app/models/enterprise/concerns/inbox.rb +++ b/enterprise/app/models/enterprise/concerns/inbox.rb @@ -6,5 +6,6 @@ module Enterprise::Concerns::Inbox has_one :captain_assistant, through: :captain_inbox, class_name: 'Captain::Assistant' + has_many :inbox_capacity_limits, dependent: :destroy end end diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb index 850bd0064..8a135f484 100644 --- a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb +++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb @@ -57,4 +57,40 @@ module Enterprise::AutoAssignment::AssignmentService def account inbox.account end + + # Override to apply exclusion rules + def unassigned_conversations(limit) + scope = inbox.conversations.unassigned.open + + # Apply exclusion rules from capacity policy or assignment policy + scope = apply_exclusion_rules(scope) + + # Apply conversation priority from config + scope = apply_conversation_priority(scope) + scope.limit(limit) + end + + def apply_exclusion_rules(scope) + capacity_policy = inbox.inbox_capacity_limits.first&.agent_capacity_policy + return scope unless capacity_policy + + exclusion_rules = capacity_policy.exclusion_rules || {} + scope = apply_label_exclusions(scope, exclusion_rules['excluded_labels']) + apply_age_exclusions(scope, exclusion_rules['exclude_older_than_hours']) + end + + def apply_label_exclusions(scope, excluded_labels) + return scope if excluded_labels.blank? + + scope.tagged_with(excluded_labels, exclude: true, on: :labels) + end + + def apply_age_exclusions(scope, hours_threshold) + return scope if hours_threshold.blank? + + hours = hours_threshold.to_i + return scope unless hours.positive? + + scope.where('conversations.created_at >= ?', hours.hours.ago) + end end diff --git a/spec/enterprise/auto_assignment/assignment_service_spec.rb b/spec/enterprise/auto_assignment/assignment_service_spec.rb new file mode 100644 index 000000000..055d7673c --- /dev/null +++ b/spec/enterprise/auto_assignment/assignment_service_spec.rb @@ -0,0 +1,181 @@ +require 'rails_helper' + +RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:agent1) { create(:user, account: account, name: 'Agent 1') } + let(:agent2) { create(:user, account: account, name: 'Agent 2') } + let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) } + + before do + # Create inbox members + create(:inbox_member, inbox: inbox, user: agent1) + create(:inbox_member, inbox: inbox, user: agent2) + + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true) + + # Set agents as online + OnlineStatusTracker.update_presence(account.id, 'User', agent1.id) + OnlineStatusTracker.set_status(account.id, agent1.id, 'online') + OnlineStatusTracker.update_presence(account.id, 'User', agent2.id) + OnlineStatusTracker.set_status(account.id, agent2.id, 'online') + end + + describe 'exclusion rules' do + let(:capacity_policy) { create(:agent_capacity_policy, account: account) } + let(:label1) { create(:label, account: account, title: 'high-priority') } + let(:label2) { create(:label, account: account, title: 'vip') } + + before do + create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10) + inbox.enable_auto_assignment = true + inbox.save! + end + + context 'when excluding conversations by label' do + let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) } + let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) } + + before do + conversation_with_label.update_labels([label1.title]) + + capacity_policy.update!(exclusion_rules: { + 'excluded_labels' => [label1.title] + }) + end + + it 'excludes conversations with specified labels' do + # First check conversations are unassigned + expect(conversation_with_label.assignee).to be_nil + expect(conversation_without_label.assignee).to be_nil + + # Run bulk assignment + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + # Only the conversation without label should be assigned + expect(assigned_count).to eq(1) + expect(conversation_with_label.reload.assignee).to be_nil + expect(conversation_without_label.reload.assignee).to be_present + end + + it 'handles bulk assignment correctly' do + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + # Only 1 conversation should be assigned (the one without label) + expect(assigned_count).to eq(1) + expect(conversation_with_label.reload.assignee).to be_nil + expect(conversation_without_label.reload.assignee).to be_present + end + + it 'excludes conversations with multiple labels' do + conversation_without_label.update_labels([label2.title]) + + capacity_policy.update!(exclusion_rules: { + 'excluded_labels' => [label1.title, label2.title] + }) + + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + # Both conversations should be excluded + expect(assigned_count).to eq(0) + expect(conversation_with_label.reload.assignee).to be_nil + expect(conversation_without_label.reload.assignee).to be_nil + end + end + + context 'when excluding conversations by age' do + let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) } + let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) } + + before do + capacity_policy.update!(exclusion_rules: { + 'exclude_older_than_hours' => 24 + }) + end + + it 'excludes conversations older than specified hours' do + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + # Only recent conversation should be assigned + expect(assigned_count).to eq(1) + expect(old_conversation.reload.assignee).to be_nil + expect(recent_conversation.reload.assignee).to be_present + end + + it 'handles different time thresholds' do + capacity_policy.update!(exclusion_rules: { + 'exclude_older_than_hours' => 2 + }) + + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + # Only conversation created within 2 hours should be assigned + expect(assigned_count).to eq(1) + expect(recent_conversation.reload.assignee).to be_present + end + end + + context 'when combining exclusion rules' do + it 'applies both exclusion rules' do + # Create conversations + old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) + old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) + recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) + recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) + + # Add labels + old_conversation_with_label.update_labels([label1.title]) + recent_conversation_with_label.update_labels([label1.title]) + + capacity_policy.update!(exclusion_rules: { + 'excluded_labels' => [label1.title], + 'exclude_older_than_hours' => 24 + }) + + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + # Only recent conversation without label should be assigned + expect(assigned_count).to eq(1) + expect(old_conversation_with_label.reload.assignee).to be_nil + expect(old_conversation_without_label.reload.assignee).to be_nil + expect(recent_conversation_with_label.reload.assignee).to be_nil + expect(recent_conversation_without_label.reload.assignee).to be_present + end + end + + context 'when exclusion rules are empty' do + let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) } + let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) } + + before do + capacity_policy.update!(exclusion_rules: {}) + end + + it 'assigns all eligible conversations' do + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(2) + expect(conversation1.reload.assignee).to be_present + expect(conversation2.reload.assignee).to be_present + end + end + + context 'when no capacity policy exists' do + let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) } + let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) } + + before do + InboxCapacityLimit.destroy_all + end + + it 'assigns all eligible conversations without exclusions' do + assigned_count = assignment_service.perform_bulk_assignment(limit: 10) + + expect(assigned_count).to eq(2) + expect(conversation1.reload.assignee).to be_present + expect(conversation2.reload.assignee).to be_present + end + end + end +end diff --git a/spec/services/auto_assignment/agent_assignment_service_spec.rb b/spec/services/auto_assignment/agent_assignment_service_spec.rb index a107ce263..16337dc04 100644 --- a/spec/services/auto_assignment/agent_assignment_service_spec.rb +++ b/spec/services/auto_assignment/agent_assignment_service_spec.rb @@ -1,3 +1,5 @@ +require 'rails_helper' + RSpec.describe AutoAssignment::AgentAssignmentService do let!(:account) { create(:account) } let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }