feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket - https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents ## Description Assignment policies now skip stale unassigned conversations automatically. Each policy carries an age threshold (defaults to 7 days), so auto-assignment only picks up recent backlog instead of draining very old, forgotten conversations. The threshold is configurable per policy and can be cleared to assign conversations regardless of age. Previously this control existed only on Enterprise capacity policies (`exclude_older_than_hours`); it now lives on the assignment policy itself, so every V2 inbox benefits without needing a capacity policy. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Local UI flows ## Screenshots? <img width="645" height="822" alt="image" src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077" /> ## 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: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
co-authored by
Sony Mathew
parent
647cfc2d83
commit
df79c0bbde
@@ -90,8 +90,8 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
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) }
|
||||
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) }
|
||||
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
@@ -124,10 +124,10 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
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)
|
||||
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
|
||||
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
|
||||
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
|
||||
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
|
||||
|
||||
# Add labels
|
||||
old_conversation_with_label.update_labels([label1.title])
|
||||
@@ -182,5 +182,23 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when excluding by age via the assignment policy' do
|
||||
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) }
|
||||
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
InboxCapacityLimit.destroy_all
|
||||
assignment_policy.update!(exclude_older_than_hours: 24)
|
||||
end
|
||||
|
||||
it 'skips conversations older than the policy threshold without a capacity policy' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,19 @@ RSpec.describe AssignmentPolicy do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'exclude_older_than_hours validations' do
|
||||
it 'requires exclude_older_than_hours to be greater than 0' do
|
||||
policy = build(:assignment_policy, exclude_older_than_hours: 0)
|
||||
expect(policy).not_to be_valid
|
||||
expect(policy.errors[:exclude_older_than_hours]).to include('must be greater than 0')
|
||||
end
|
||||
|
||||
it 'allows exclude_older_than_hours to be nil' do
|
||||
policy = build(:assignment_policy, exclude_older_than_hours: nil)
|
||||
expect(policy).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enum values' do
|
||||
let(:assignment_policy) { create(:assignment_policy) }
|
||||
|
||||
|
||||
@@ -192,6 +192,55 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with age-based exclusion' do
|
||||
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
|
||||
|
||||
before do
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
|
||||
|
||||
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
|
||||
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
|
||||
|
||||
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
|
||||
allow(rate_limiter).to receive(:within_limit?).and_return(true)
|
||||
allow(rate_limiter).to receive(:track_assignment)
|
||||
end
|
||||
|
||||
it 'skips conversations inactive beyond the policy threshold' do
|
||||
assignment_policy.update!(exclude_older_than_hours: 24)
|
||||
old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
|
||||
recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'assigns reopened conversations created long ago but recently active' do
|
||||
assignment_policy.update!(exclude_older_than_hours: 24)
|
||||
reopened_conversation = create(:conversation, inbox: inbox, assignee: nil,
|
||||
created_at: 30.days.ago, last_activity_at: 1.hour.ago)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(reopened_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
it 'assigns conversations regardless of age when threshold is nil' do
|
||||
assignment_policy.update!(exclude_older_than_hours: nil)
|
||||
old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 30.days.ago)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fair distribution' do
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
|
||||
Reference in New Issue
Block a user