From 5fd3d5e036c6b4fe3e3beb164c59b6dc9bd830b3 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:39:14 +0530 Subject: [PATCH] feat: allow zero conversation limit capacity policy (#13964) ## Description Two improvements to Agent Capacity Policy: **1. Support exclusion via zero conversation limit** Allow `conversation_limit` to be `0` on inbox capacity limits. Agents with a zero limit are excluded from auto-assignment for that inbox while remaining members for manual assignment. **2. Fix exclusion rules duration input** - Default changed from `10` to `null` so time-based exclusion isn't applied unless explicitly set. - Minimum lowered from 10 to 1 minute. - `DurationInput` updated to handle `null` values correctly. ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Added model and capacity service specs for zero-limit exclusion behavior. - Tested manually via UI flows ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../components/ExclusionRules.vue | 6 +-- .../components/InboxCapacityLimits.vue | 3 +- .../components-next/input/DurationInput.vue | 6 +++ .../pages/AgentCapacityEditPage.vue | 2 +- .../components/AgentCapacityPolicyForm.vue | 6 +-- enterprise/app/models/inbox_capacity_limit.rb | 2 +- .../models/inbox_capacity_limit_spec.rb | 14 +++++- .../auto_assignment/capacity_service_spec.rb | 49 +++++++++++++++++++ 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue index 3ac7dfd5f..2aafec45b 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue @@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', { const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', { type: Number, - default: 10, + default: null, }); -// Duration limits: 10 minutes to 999 days (in minutes) -const MIN_DURATION_MINUTES = 10; +// Duration limits: 1 minute to 999 days (in minutes) +const MIN_DURATION_MINUTES = 1; const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes const { t } = useI18n(); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue index b31248653..7b79e5280 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue @@ -27,7 +27,7 @@ const { t } = useI18n(); const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY'; const DEFAULT_CONVERSATION_LIMIT = 10; -const MIN_CONVERSATION_LIMIT = 1; +const MIN_CONVERSATION_LIMIT = 0; const MAX_CONVERSATION_LIMIT = 100000; const selectedInboxIds = computed( @@ -42,6 +42,7 @@ const availableInboxes = computed(() => const isLimitValid = limit => { return ( + Number.isInteger(limit.conversationLimit) && limit.conversationLimit >= MIN_CONVERSATION_LIMIT && limit.conversationLimit <= MAX_CONVERSATION_LIMIT ); diff --git a/app/javascript/dashboard/components-next/input/DurationInput.vue b/app/javascript/dashboard/components-next/input/DurationInput.vue index 7a9fbc12d..b0597648a 100644 --- a/app/javascript/dashboard/components-next/input/DurationInput.vue +++ b/app/javascript/dashboard/components-next/input/DurationInput.vue @@ -32,6 +32,7 @@ const convertToMinutes = newValue => { const transformedValue = computed({ get() { + if (duration.value == null) return null; if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === DURATION_UNITS.HOURS) return Math.floor(duration.value / 60); @@ -41,6 +42,10 @@ const transformedValue = computed({ return 0; }, set(newValue) { + if (newValue == null || newValue === '') { + duration.value = null; + return; + } let minuteValue = convertToMinutes(newValue); duration.value = Math.min(Math.max(minuteValue, props.min), props.max); @@ -53,6 +58,7 @@ const transformedValue = computed({ // this might create some confusion, especially when saving // this watcher fixes it by rounding the duration basically, to the nearest unit value watch(unit, () => { + if (duration.value == null) return; let adjustedValue = convertToMinutes(transformedValue.value); duration.value = Math.min(Math.max(adjustedValue, props.min), props.max); }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue index 390608733..31d0fa3e1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue @@ -81,7 +81,7 @@ const formData = computed(() => ({ ...(selectedPolicy.value?.exclusionRules?.excludedLabels || []), ], excludeOlderThanHours: - selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10, + selectedPolicy.value?.exclusionRules?.excludeOlderThanHours ?? null, }, inboxCapacityLimits: selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue index 24c4f0a38..ec82e0c16 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue @@ -17,7 +17,7 @@ const props = defineProps({ enabled: false, exclusionRules: { excludedLabels: [], - excludeOlderThanHours: 10, + excludeOlderThanHours: null, }, inboxCapacityLimits: [], }), @@ -84,7 +84,7 @@ const state = reactive({ description: '', exclusionRules: { excludedLabels: [], - excludeOlderThanHours: 10, + excludeOlderThanHours: null, }, inboxCapacityLimits: [], }); @@ -120,7 +120,7 @@ const resetForm = () => { description: '', exclusionRules: { excludedLabels: [], - excludeOlderThanHours: 10, + excludeOlderThanHours: null, }, inboxCapacityLimits: [], }); diff --git a/enterprise/app/models/inbox_capacity_limit.rb b/enterprise/app/models/inbox_capacity_limit.rb index 709dd809f..7ae74d427 100644 --- a/enterprise/app/models/inbox_capacity_limit.rb +++ b/enterprise/app/models/inbox_capacity_limit.rb @@ -19,6 +19,6 @@ class InboxCapacityLimit < ApplicationRecord belongs_to :agent_capacity_policy belongs_to :inbox - validates :conversation_limit, presence: true, numericality: { greater_than: 0, only_integer: true } + validates :conversation_limit, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true } validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id } end diff --git a/spec/enterprise/models/inbox_capacity_limit_spec.rb b/spec/enterprise/models/inbox_capacity_limit_spec.rb index 8c3f76dc4..64d9b08a0 100644 --- a/spec/enterprise/models/inbox_capacity_limit_spec.rb +++ b/spec/enterprise/models/inbox_capacity_limit_spec.rb @@ -9,10 +9,22 @@ RSpec.describe InboxCapacityLimit, type: :model do subject { create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) } it { is_expected.to validate_presence_of(:conversation_limit) } - it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than(0).only_integer } + it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than_or_equal_to(0).only_integer } it { is_expected.to validate_uniqueness_of(:inbox_id).scoped_to(:agent_capacity_policy_id) } end + describe 'zero conversation limit (exclusion policy)' do + it 'allows conversation_limit of 0' do + limit = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox, conversation_limit: 0) + expect(limit).to be_valid + end + + it 'rejects negative conversation_limit' do + limit = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox, conversation_limit: -1) + expect(limit).not_to be_valid + end + end + describe 'uniqueness constraint' do it 'prevents duplicate inbox limits for the same policy' do create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) diff --git a/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb index c34eaad64..f1a7cfe49 100644 --- a/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb +++ b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb @@ -86,6 +86,55 @@ RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do end end + describe 'exclusion policy (zero conversation limit)' do + let(:excluded_agent) { create(:user, account: account, role: :agent, availability: :online) } + let(:exclusion_policy) { create(:agent_capacity_policy, account: account, name: 'Exclusion Policy') } + + before do + create(:inbox_capacity_limit, + agent_capacity_policy: exclusion_policy, + inbox: inbox, + conversation_limit: 0) + + excluded_agent.account_users.find_by(account: account) + .update!(agent_capacity_policy: exclusion_policy) + + create(:inbox_member, inbox: inbox, user: excluded_agent) + + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ + excluded_agent.id.to_s => 'online', + agent_with_capacity.id.to_s => 'online', + agent_without_capacity.id.to_s => 'online', + agent_at_capacity.id.to_s => 'online' + }) + end + + it 'always denies capacity for agents with zero limit' do + capacity_service = described_class.new + expect(capacity_service.agent_has_capacity?(excluded_agent, inbox)).to be false + end + + it 'denies capacity even when agent has no existing conversations' do + capacity_service = described_class.new + # Agent has 0 open conversations but limit is 0, so 0 < 0 is false + expect(excluded_agent.assigned_conversations.where(inbox: inbox, status: :open).count).to eq(0) + expect(capacity_service.agent_has_capacity?(excluded_agent, inbox)).to be false + end + + it 'excludes zero-limit agents from available agents list' do + capacity_service = described_class.new + online_agents = inbox.available_agents + filtered_agents = online_agents.select do |inbox_member| + capacity_service.agent_has_capacity?(inbox_member.user, inbox) + end + available_users = filtered_agents.map(&:user) + + expect(available_users).not_to include(excluded_agent) + expect(available_users).to include(agent_with_capacity) + expect(available_users).to include(agent_without_capacity) + end + end + describe 'assignment with capacity' do let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }