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 <muhsinkeramam@gmail.com>
This commit is contained in:
Tanmay Deep Sharma
2026-04-06 11:39:14 +05:30
committed by GitHub
parent 6f5ad8f372
commit 5fd3d5e036
8 changed files with 78 additions and 10 deletions

View File

@@ -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();

View File

@@ -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
);

View File

@@ -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);
});

View File

@@ -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 => ({

View File

@@ -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: [],
});

View File

@@ -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

View File

@@ -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)

View File

@@ -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) }