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', { const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number, type: Number,
default: 10, default: null,
}); });
// Duration limits: 10 minutes to 999 days (in minutes) // Duration limits: 1 minute to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10; const MIN_DURATION_MINUTES = 1;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -27,7 +27,7 @@ const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY'; const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10; const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1; const MIN_CONVERSATION_LIMIT = 0;
const MAX_CONVERSATION_LIMIT = 100000; const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed( const selectedInboxIds = computed(
@@ -42,6 +42,7 @@ const availableInboxes = computed(() =>
const isLimitValid = limit => { const isLimitValid = limit => {
return ( return (
Number.isInteger(limit.conversationLimit) &&
limit.conversationLimit >= MIN_CONVERSATION_LIMIT && limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT limit.conversationLimit <= MAX_CONVERSATION_LIMIT
); );

View File

@@ -32,6 +32,7 @@ const convertToMinutes = newValue => {
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (duration.value == null) return null;
if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS) if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60); return Math.floor(duration.value / 60);
@@ -41,6 +42,10 @@ const transformedValue = computed({
return 0; return 0;
}, },
set(newValue) { set(newValue) {
if (newValue == null || newValue === '') {
duration.value = null;
return;
}
let minuteValue = convertToMinutes(newValue); let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); 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 might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value // this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => { watch(unit, () => {
if (duration.value == null) return;
let adjustedValue = convertToMinutes(transformedValue.value); let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max); duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
}); });

View File

@@ -81,7 +81,7 @@ const formData = computed(() => ({
...(selectedPolicy.value?.exclusionRules?.excludedLabels || []), ...(selectedPolicy.value?.exclusionRules?.excludedLabels || []),
], ],
excludeOlderThanHours: excludeOlderThanHours:
selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10, selectedPolicy.value?.exclusionRules?.excludeOlderThanHours ?? null,
}, },
inboxCapacityLimits: inboxCapacityLimits:
selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({ selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({

View File

@@ -17,7 +17,7 @@ const props = defineProps({
enabled: false, enabled: false,
exclusionRules: { exclusionRules: {
excludedLabels: [], excludedLabels: [],
excludeOlderThanHours: 10, excludeOlderThanHours: null,
}, },
inboxCapacityLimits: [], inboxCapacityLimits: [],
}), }),
@@ -84,7 +84,7 @@ const state = reactive({
description: '', description: '',
exclusionRules: { exclusionRules: {
excludedLabels: [], excludedLabels: [],
excludeOlderThanHours: 10, excludeOlderThanHours: null,
}, },
inboxCapacityLimits: [], inboxCapacityLimits: [],
}); });
@@ -120,7 +120,7 @@ const resetForm = () => {
description: '', description: '',
exclusionRules: { exclusionRules: {
excludedLabels: [], excludedLabels: [],
excludeOlderThanHours: 10, excludeOlderThanHours: null,
}, },
inboxCapacityLimits: [], inboxCapacityLimits: [],
}); });

View File

@@ -19,6 +19,6 @@ class InboxCapacityLimit < ApplicationRecord
belongs_to :agent_capacity_policy belongs_to :agent_capacity_policy
belongs_to :inbox 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 } validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id }
end end

View File

@@ -9,10 +9,22 @@ RSpec.describe InboxCapacityLimit, type: :model do
subject { create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) } 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_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) } it { is_expected.to validate_uniqueness_of(:inbox_id).scoped_to(:agent_capacity_policy_id) }
end 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 describe 'uniqueness constraint' do
it 'prevents duplicate inbox limits for the same policy' do it 'prevents duplicate inbox limits for the same policy' do
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) 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
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 describe 'assignment with capacity' do
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) } let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }