feat: Assignment service (v2) (#12320)

## Linear Link

 
## Description

This PR introduces a new robust auto-assignment system for conversations
in Chatwoot. The system replaces the existing round-robin assignment
with a more sophisticated service-based architecture that supports
multiple assignment strategies, rate limiting, and Enterprise features
like capacity-based assignment and balanced distribution.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Unit test cases
- Test conversations getting assigned on status change to open
- Test the job directly via rails console

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds a new service-based auto-assignment system with scheduled jobs,
rate limiting, enterprise capacity/balanced selection, and wiring via
inbox/handler; includes Redis helpers and comprehensive tests.
> 
> - **Auto-assignment v2 (core services)**:
> - Add `AutoAssignment::AssignmentService` with bulk assignment,
configurable conversation priority, RR selection, and per-agent rate
limiting via `AutoAssignment::RateLimiter`.
>   - Add `AutoAssignment::RoundRobinSelector` for agent selection.
> - **Jobs & scheduling**:
> - Add `AutoAssignment::AssignmentJob` (per-inbox bulk assign;
env-based limit) and `AutoAssignment::PeriodicAssignmentJob` (batch over
accounts/inboxes).
> - Schedule periodic run in `config/schedule.yml`
(`periodic_assignment_job`).
> - **Model/concerns wiring**:
> - Include `InboxAgentAvailability` in `Inbox`; add
`Inbox#auto_assignment_v2_enabled?`.
> - Update `AutoAssignmentHandler` to trigger v2 job when
`auto_assignment_v2_enabled?`, else fallback to legacy.
> - **Enterprise extensions**:
> - Add `Enterprise::InboxAgentAvailability` (capacity-aware filtering)
and `Enterprise::Concerns::Inbox` association `inbox_capacity_limits`.
> - Extend service via `Enterprise::AutoAssignment::AssignmentService`
(policy-driven config, capacity filtering, exclusion rules) and add
selectors/services: `BalancedSelector`, `CapacityService`.
> - **Infrastructure**:
> - Enhance `Redis::Alfred` with `expire`, key scan/count, and extended
ZSET helpers (`zadd`, `zcount`, `zcard`, `zrangebyscore`).
> - **Tests**:
> - Add specs for jobs, core service, rate limiter, RR selector, and
enterprise features (capacity, balanced selection, exclusions).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0ebe187c8aea73765b0122a44b18d6f465c2477f. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Tanmay Deep Sharma
2025-11-17 10:08:25 +05:30
committed by GitHub
parent 2c4e65d68e
commit c9823d9409
24 changed files with 1622 additions and 5 deletions

View File

@@ -0,0 +1,185 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
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)
# Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
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

View File

@@ -0,0 +1,88 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::BalancedSelector do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:selector) { described_class.new(inbox: inbox) }
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
describe '#select_agent' do
context 'when selecting based on workload' do
let(:available_agents) { [member1, member2, member3] }
it 'selects the agent with least open conversations' do
# Agent1 has 3 open conversations
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') }
# Agent2 has 1 open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
# Agent3 has 2 open conversations
2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') }
selected_agent = selector.select_agent(available_agents)
# Should select agent2 as they have the least conversations
expect(selected_agent).to eq(agent2)
end
it 'considers only open conversations' do
# Agent1 has 1 open and 3 resolved conversations
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') }
# Agent2 has 2 open conversations
2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') }
selected_agent = selector.select_agent([member1, member2])
# Should select agent1 as they have fewer open conversations
expect(selected_agent).to eq(agent1)
end
it 'selects any agent when agents have equal workload' do
# All agents have same number of conversations
[member1, member2, member3].each do |member|
create(:conversation, inbox: inbox, assignee: member.user, status: 'open')
end
selected_agent = selector.select_agent(available_agents)
# Should select one of the agents (when equal, min_by returns the first one it finds)
expect([agent1, agent2, agent3]).to include(selected_agent)
end
end
context 'when no agents are available' do
it 'returns nil' do
selected_agent = selector.select_agent([])
expect(selected_agent).to be_nil
end
end
context 'when one agent is available' do
it 'returns that agent' do
selected_agent = selector.select_agent([member1])
expect(selected_agent).to eq(agent1)
end
end
context 'with new agents (no conversations)' do
it 'prioritizes agents with no conversations' do
# Agent1 and 2 have conversations
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
# Agent3 is new with no conversations
selected_agent = selector.select_agent([member1, member2, member3])
expect(selected_agent).to eq(agent3)
end
end
end
end

View File

@@ -0,0 +1,119 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
# Assignment policy with rate limiting
let(:assignment_policy) do
create(:assignment_policy,
account: account,
enabled: true,
fair_distribution_limit: 5,
fair_distribution_window: 3600)
end
# Agent capacity policy
let(:agent_capacity_policy) do
create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
end
# Agents with different capacity settings
let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
before do
# Create inbox assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
# Set inbox capacity limit
create(:inbox_capacity_limit,
agent_capacity_policy: agent_capacity_policy,
inbox: inbox,
conversation_limit: 3)
# Assign capacity policy to specific agents
agent_with_capacity.account_users.find_by(account: account)
.update!(agent_capacity_policy: agent_capacity_policy)
agent_at_capacity.account_users.find_by(account: account)
.update!(agent_capacity_policy: agent_capacity_policy)
# Create inbox members
create(:inbox_member, inbox: inbox, user: agent_with_capacity)
create(:inbox_member, inbox: inbox, user: agent_without_capacity)
create(:inbox_member, inbox: inbox, user: agent_at_capacity)
# Mock online status
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
agent_with_capacity.id.to_s => 'online',
agent_without_capacity.id.to_s => 'online',
agent_at_capacity.id.to_s => 'online'
})
# Enable assignment_v2 feature
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Create existing assignments for agent_at_capacity (at limit)
3.times do
create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
end
end
describe 'capacity filtering' do
it 'excludes agents at capacity' do
# Get available agents respecting capacity
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).to include(agent_with_capacity)
expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
expect(available_users).not_to include(agent_at_capacity) # At capacity limit
end
it 'respects inbox-specific capacity limits' do
capacity_service = described_class.new
expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
end
end
describe 'assignment with capacity' do
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
it 'assigns to agents with available capacity' do
# Create conversation before assignment
conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent) do |agents|
agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
end
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
end
it 'returns false when all agents are at capacity' do
# Fill up remaining agents
3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
# agent_without_capacity has no limit, so should still be available
conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
end
end
end