feat: add assignment service

This commit is contained in:
Tanmay Sharma
2025-08-28 17:03:11 +07:00
parent 472493b9af
commit eacfa2e19a
23 changed files with 1153 additions and 10 deletions

View File

@@ -0,0 +1,28 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
service = AutoAssignment::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
success_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_success',
assigned_count: assigned_count,
inbox_id: inbox.id)
Rails.logger.info success_message
rescue StandardError => e
error_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_failed',
inbox_id: inbox.id,
error_message: e.message)
Rails.logger.error error_message
raise e if Rails.env.test?
end
private
def bulk_assignment_limit
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
end
end

View File

@@ -0,0 +1,15 @@
class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
next unless account.feature_enabled?('assignment_v2')
account.inboxes.joins(:assignment_policy).find_each do |inbox|
next unless inbox.assignment_v2_enabled?
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
end
end
end
end

View File

@@ -14,7 +14,13 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
if inbox.auto_assignment_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
end
def should_run_auto_assignment?

View File

@@ -0,0 +1,28 @@
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
end
InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')

View File

@@ -44,6 +44,7 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -190,6 +191,10 @@ class Inbox < ApplicationRecord
members.ids
end
def auto_assignment_enabled?
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
end
private
def default_name_for_blank_name

View File

@@ -0,0 +1,99 @@
class AutoAssignment::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
agent = find_available_agent
return false unless agent
assign_conversation(conversation, agent)
end
def perform_bulk_assignment(limit: 100)
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0
unassigned_conversations(limit).find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def assignable?(conversation)
inbox.enable_auto_assignment? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply conversation priority from config
scope = apply_conversation_priority(scope)
scope.limit(limit)
end
def apply_conversation_priority(scope)
case assignment_config['conversation_priority']
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
end
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
rate_limiter.within_limit?
end
end
def assign_conversation(conversation, agent)
conversation.update!(assignee: agent)
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AutoAssignment failed for conversation #{conversation.id}: #{e.message}"
false
end
def dispatch_assignment_event(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def build_rate_limiter(agent)
AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent)
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')

View File

@@ -0,0 +1,49 @@
class AutoAssignment::RateLimiter
pattr_initialize [:inbox!, :agent!]
def within_limit?
return true unless enabled?
current_count < limit
end
def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end
def current_count
return 0 unless enabled?
pattern = assignment_key_pattern
Redis::Alfred.keys_count(pattern)
end
private
def enabled?
limit.present? && limit.positive?
end
def limit
config['fair_distribution_limit']&.to_i
end
def window
config['fair_distribution_window']&.to_i || 3600
end
def config
@config ||= inbox.auto_assignment_config || {}
end
def assignment_key_pattern
"assignment:#{inbox.id}:agent:#{agent.id}:*"
end
def build_assignment_key(conversation_id)
"assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation_id}"
end
end

View File

@@ -0,0 +1,16 @@
class AutoAssignment::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end

View File

@@ -374,6 +374,11 @@ en:
%{format_messages}
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
jobs:
auto_assignment:
assignment_job:
bulk_assignment_success: 'Assigned %{assigned_count} conversations for inbox %{inbox_id}'
bulk_assignment_failed: 'Bulk assignment failed for inbox %{inbox_id}: %{error_message}'
portals:
send_instructions:
email_required: 'Email is required'

View File

@@ -46,3 +46,9 @@ delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
# executed every 30 minutes for assignment_v2
periodic_assignment_job:
cron: '*/30 * * * *'
class: 'AutoAssignment::PeriodicAssignmentJob'
queue: scheduled_jobs

View File

@@ -0,0 +1,20 @@
module Enterprise::AssignmentPolicy
def assignment_order=(value)
if value.to_s == 'balanced'
write_attribute(:assignment_order, 1)
else
super
end
end
def assignment_order
value = read_attribute(:assignment_order)
return 'balanced' if value == 1
super
end
def balanced?
self[:assignment_order] == 1
end
end

View File

@@ -0,0 +1,31 @@
module Enterprise::InboxAgentAvailability
extend ActiveSupport::Concern
def member_ids_with_assignment_capacity
return member_ids unless capacity_filtering_enabled?
# Get online agents with capacity
agents = available_agents
agents = filter_by_capacity(agents) if capacity_filtering_enabled?
agents.map(&:user_id)
end
private
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_filtering_enabled?
inbox_members_scope.select do |inbox_member|
capacity_service.agent_has_capacity?(inbox_member.user, self)
end
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def capacity_service
@capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
end
end

View File

@@ -0,0 +1,60 @@
module Enterprise::AutoAssignment::AssignmentService
private
# Override assignment config to use policy if available
def assignment_config
return super unless policy
{
'conversation_priority' => policy.conversation_priority,
'fair_distribution_limit' => policy.fair_distribution_limit,
'fair_distribution_window' => policy.fair_distribution_window,
'balanced' => policy.balanced?
}.compact
end
# Override to check policy first
def assignment_enabled?
return policy.enabled? if policy
super
end
# Extend agent finding to add capacity checks
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
selector = policy&.balanced? ? balanced_selector : round_robin_selector
selector.select_agent(agents)
end
def filter_agents_by_capacity(agents)
return agents unless capacity_filtering_enabled?
capacity_service = Enterprise::AutoAssignment::CapacityService.new
agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) }
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def balanced_selector
@balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox)
end
def policy
@policy ||= inbox.assignment_policy
end
def account
inbox.account
end
end

View File

@@ -0,0 +1,26 @@
class Enterprise::AutoAssignment::BalancedSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_users = available_agents.map(&:user)
assignment_counts = fetch_assignment_counts(agent_users)
agent_users.min_by { |user| assignment_counts[user.id] || 0 }
end
private
def fetch_assignment_counts(users)
user_ids = users.map(&:id)
counts = inbox.conversations
.open
.where(assignee_id: user_ids)
.group(:assignee_id)
.count
Hash.new(0).merge(counts)
end
end

View File

@@ -0,0 +1,44 @@
class Enterprise::AutoAssignment::CapacityService
def agent_has_capacity?(user, inbox)
# Get the account_user for this specific account
account_user = user.account_users.find_by(account: inbox.account)
# If no account_user or no capacity policy, agent has unlimited capacity
return true unless account_user&.agent_capacity_policy
policy = account_user.agent_capacity_policy
# Check if there's a specific limit for this inbox
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
# If no specific limit for this inbox, agent has unlimited capacity for this inbox
return true unless inbox_limit
# Count current open conversations for this agent in this inbox
current_count = user.assigned_conversations
.where(inbox: inbox, status: :open)
.count
# Agent has capacity if current count is below the limit
current_count < inbox_limit.conversation_limit
end
def agent_capacity_status(user, inbox)
account_user = user.account_users.find_by(account: inbox.account)
return { has_capacity: true, current: 0, limit: nil } unless account_user&.agent_capacity_policy
policy = account_user.agent_capacity_policy
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
return { has_capacity: true, current: 0, limit: nil } unless inbox_limit
current_count = user.assigned_conversations
.where(inbox: inbox, status: :open)
.count
{
has_capacity: current_count < inbox_limit.conversation_limit,
current: current_count,
limit: inbox_limit.conversation_limit
}
end
end

View File

@@ -35,6 +35,25 @@ module Redis::Alfred
$alfred.with { |conn| conn.exists?(key) }
end
# set expiry on a key in seconds
def expire(key, seconds)
$alfred.with { |conn| conn.expire(key, seconds) }
end
# scan keys matching a pattern
def scan_each(match: nil, count: 100, &)
$alfred.with do |conn|
conn.scan_each(match: match, count: count, &)
end
end
# count keys matching a pattern
def keys_count(pattern)
count = 0
scan_each(match: pattern) { count += 1 }
count
end
# list operations
def llen(key)
@@ -81,8 +100,15 @@ module Redis::Alfred
# sorted set operations
# add score and value for a key
def zadd(key, score, value)
$alfred.with { |conn| conn.zadd(key, score, value) }
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
if value.nil? && score.is_a?(Array)
# New syntax: score is actually an array of [score, member] pairs
$alfred.with { |conn| conn.zadd(key, score) }
else
# Support old syntax for backward compatibility
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
end
end
# get score of a value for key
@@ -90,9 +116,22 @@ module Redis::Alfred
$alfred.with { |conn| conn.zscore(key, value) }
end
# count members in a sorted set with scores within the given range
def zcount(key, min_score, max_score)
$alfred.with { |conn| conn.zcount(key, min_score, max_score) }
end
# get the number of members in a sorted set
def zcard(key)
$alfred.with { |conn| conn.zcard(key) }
end
# get values by score
def zrangebyscore(key, range_start, range_end)
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) }
def zrangebyscore(key, range_start, range_end, with_scores: false, limit: nil)
options = {}
options[:with_scores] = with_scores if with_scores
options[:limit] = limit if limit
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end, **options) }
end
# remove values by score

View File

@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
# 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
available = inbox.available_agents(check_capacity: true)
available_users = available.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) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil, status: :open) }
it 'assigns to agents with available capacity' do
# 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
expect(service.perform_for_conversation(conversation)).to be true
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)
expect(service.perform_for_conversation(conversation2)).to be true
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
end
end
describe 'capacity status' do
it 'provides accurate capacity status for agent at capacity' do
capacity_service = described_class.new
status = capacity_service.agent_capacity_status(agent_at_capacity, inbox)
expect(status[:has_capacity]).to be false
expect(status[:current]).to eq(3)
expect(status[:limit]).to eq(3)
end
it 'provides accurate capacity status for agent with capacity' do
capacity_service = described_class.new
status = capacity_service.agent_capacity_status(agent_with_capacity, inbox)
expect(status[:has_capacity]).to be true
expect(status[:current]).to eq(0)
expect(status[:limit]).to eq(3)
end
it 'provides accurate capacity status for agent without limit' do
capacity_service = described_class.new
status = capacity_service.agent_capacity_status(agent_without_capacity, inbox)
expect(status[:has_capacity]).to be true
expect(status[:limit]).to be_nil
end
end
end

View File

@@ -1,5 +1,3 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AgentAssignmentService do
let!(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }

View File

@@ -0,0 +1,81 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AssignmentService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
let(:service) { described_class.new(inbox: inbox) }
before do
create(:inbox_member, inbox: inbox, user: agent)
allow(OnlineStatusTracker).to receive(:get_available_users)
.and_return({ agent.id.to_s => 'online' })
end
def create_test_conversation(attrs = {})
conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil, status: :open))
allow(conversation).to receive(:run_auto_assignment).and_return(nil)
conversation.save!
conversation
end
describe 'basic assignment' do
context 'when auto assignment is enabled' do
before { inbox.update!(enable_auto_assignment: true) }
it 'assigns an available agent to conversation' do
conversation = create_test_conversation
result = service.perform_for_conversation(conversation)
expect(result).to be true
expect(conversation.reload.assignee).to eq(agent)
end
it 'returns false when no agents are online' do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
conversation = create_test_conversation
result = service.perform_for_conversation(conversation)
expect(result).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when auto assignment is disabled' do
before { inbox.update!(enable_auto_assignment: false) }
it 'does not assign any agent' do
conversation = create_test_conversation
result = service.perform_for_conversation(conversation)
expect(result).to be false
expect(conversation.reload.assignee).to be_nil
end
end
end
describe 'assignment conditions' do
before { inbox.update!(enable_auto_assignment: true) }
it 'only assigns to open conversations' do
resolved_conversation = create_test_conversation(status: 'resolved')
result = service.perform_for_conversation(resolved_conversation)
expect(result).to be false
end
it 'does not reassign already assigned conversations' do
other_agent = create(:user, account: account, role: :agent)
assigned_conversation = create_test_conversation(assignee: other_agent)
result = service.perform_for_conversation(assigned_conversation)
expect(result).to be false
expect(assigned_conversation.reload.assignee).to eq(other_agent)
end
end
end

View File

@@ -0,0 +1,126 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AssignmentService do
let(:account) { create(:account) }
let(:inbox) do
create(:inbox,
account: account,
enable_auto_assignment: true,
auto_assignment_config: {
'fair_distribution_limit' => 2,
'fair_distribution_window' => 3600
})
end
let(:service) { 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) }
before do
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
allow(OnlineStatusTracker).to receive(:get_available_users)
.and_return({
agent1.id.to_s => 'online',
agent2.id.to_s => 'online'
})
# Clean up Redis keys for this inbox
clean_redis_keys
end
after do
clean_redis_keys
end
def clean_redis_keys
# Clean up assignment keys for both agents
pattern1 = "assignment:#{inbox.id}:agent:#{agent1.id}:*"
pattern2 = "assignment:#{inbox.id}:agent:#{agent2.id}:*"
Redis::Alfred.scan_each(match: pattern1) { |key| Redis::Alfred.delete(key) }
Redis::Alfred.scan_each(match: pattern2) { |key| Redis::Alfred.delete(key) }
end
def create_test_conversation
conversation = build(:conversation, inbox: inbox, assignee: nil, status: :open)
allow(conversation).to receive(:run_auto_assignment).and_return(nil)
conversation.save!
conversation
end
describe 'with fair distribution enabled' do
it 'respects the assignment limit per agent' do
# Each agent can handle 2 conversations
conversations = Array.new(4) { create_test_conversation }
# First 4 conversations should be assigned (2 per agent)
conversations.each do |conv|
expect(service.perform_for_conversation(conv)).to be true
end
# Verify distribution
assigned_to_agent1 = conversations.count { |c| c.reload.assignee == agent1 }
assigned_to_agent2 = conversations.count { |c| c.reload.assignee == agent2 }
expect(assigned_to_agent1).to eq(2)
expect(assigned_to_agent2).to eq(2)
# Fifth conversation should fail (both agents at limit)
fifth_conversation = create_test_conversation
expect(service.perform_for_conversation(fifth_conversation)).to be false
expect(fifth_conversation.reload.assignee).to be_nil
end
it 'tracks assignments using individual Redis keys' do
conversation = create_test_conversation
service.perform_for_conversation(conversation)
# Check that assignment key exists
pattern = "assignment:#{inbox.id}:agent:#{conversation.reload.assignee.id}:*"
count = Redis::Alfred.keys_count(pattern)
expect(count).to eq(1)
end
it 'allows new assignments after window expires' do
# Assign 2 conversations to agent1
2.times do
conversation = create_test_conversation
allow(service).to receive(:round_robin_selector).and_return(
instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1)
)
service.perform_for_conversation(conversation)
end
# Agent1 is now at limit
rate_limiter = AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent1)
expect(rate_limiter.within_limit?).to be false
# Clear Redis keys to simulate time window expiry
clean_redis_keys
# Agent1 should be available again
expect(rate_limiter.within_limit?).to be true
# New assignment should work
new_conversation = create_test_conversation
allow(service).to receive(:round_robin_selector).and_return(
instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1)
)
expect(service.perform_for_conversation(new_conversation)).to be true
end
end
describe 'without fair distribution' do
before do
inbox.update!(auto_assignment_config: {})
end
it 'assigns without limits' do
# Create more conversations than would be allowed with limits
5.times do
conversation = create_test_conversation
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).not_to be_nil
end
end
end
end

View File

@@ -0,0 +1,186 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AssignmentService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:service) { described_class.new(inbox: inbox) }
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
before do
create(:inbox_member, inbox: inbox, user: agent)
end
def create_test_conversation(attrs = {})
conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil))
# Skip the after_save callback to test our service directly
allow(conversation).to receive(:run_auto_assignment).and_return(nil)
conversation.save!
conversation
end
describe '#perform_for_conversation' do
let(:conversation) { create_test_conversation }
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
end
context 'when auto assignment is enabled' do
it 'assigns conversation to available agent' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent)
end
it 'dispatches assignee changed event' do
# The conversation update triggers its own event through callbacks
allow(Rails.configuration.dispatcher).to receive(:dispatch).and_call_original
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
Events::Types::ASSIGNEE_CHANGED,
anything,
hash_including(conversation: conversation, user: agent)
)
service.perform_for_conversation(conversation)
end
it 'returns false when no agents available' do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
expect(service.perform_for_conversation(conversation)).to be false
end
end
context 'when conversation already assigned' do
let(:conversation) { create_test_conversation(assignee: agent) }
it 'does not reassign' do
expect(service.perform_for_conversation(conversation)).to be false
end
end
context 'when conversation is not open' do
let(:conversation) { create_test_conversation(status: 'resolved') }
it 'does not assign' do
expect(service.perform_for_conversation(conversation)).to be false
end
end
end
describe '#perform_bulk_assignment' do
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
3.times { create_test_conversation(status: :open) }
end
it 'assigns multiple conversations' do
assigned_count = service.perform_bulk_assignment(limit: 2)
expect(assigned_count).to eq(2)
end
it 'respects the limit parameter' do
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(inbox.conversations.unassigned.count).to eq(2)
end
context 'when auto assignment disabled' do
before { inbox.update!(enable_auto_assignment: false) }
it 'returns 0' do
expect(service.perform_bulk_assignment).to eq(0)
end
end
end
describe 'with rate limiting' do
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
before do
create(:inbox_member, inbox: inbox, user: agent2)
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
agent.id.to_s => 'online',
agent2.id.to_s => 'online'
})
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 2,
'fair_distribution_window' => 3600
})
end
it 'filters agents based on rate limits' do
# Agent 1 has reached limit
rate_limiter_agent1 = instance_double(AutoAssignment::RateLimiter, within_limit?: false)
allow(AutoAssignment::RateLimiter).to receive(:new)
.with(inbox: inbox, agent: agent)
.and_return(rate_limiter_agent1)
# Agent 2 is within limit
rate_limiter_agent2 = instance_double(AutoAssignment::RateLimiter, within_limit?: true, track_assignment: true)
allow(AutoAssignment::RateLimiter).to receive(:new)
.with(inbox: inbox, agent: agent2)
.and_return(rate_limiter_agent2)
conversation = create_test_conversation
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent2)
end
it 'tracks assignments in Redis' do
conversation = create_test_conversation
rate_limiter = instance_double(AutoAssignment::RateLimiter, within_limit?: true)
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
expect(rate_limiter).to receive(:track_assignment).with(conversation)
service.perform_for_conversation(conversation)
end
end
describe 'conversation priority' do
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
end
context 'with longest_waiting priority' do
let!(:old_conversation) do
create_test_conversation(status: :open, created_at: 2.hours.ago, last_activity_at: 2.hours.ago)
end
let!(:new_conversation) do
create_test_conversation(status: :open, created_at: 1.hour.ago, last_activity_at: 1.hour.ago)
end
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'conversation_priority' => 'longest_waiting'
})
end
it 'assigns oldest conversation first' do
service.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).to eq(agent)
expect(new_conversation.reload.assignee).to be_nil
end
end
context 'with default priority' do
let!(:first_created) do
create_test_conversation(status: :open, created_at: 2.hours.ago)
end
let!(:second_created) do
create_test_conversation(status: :open, created_at: 1.hour.ago)
end
it 'assigns by creation time' do
service.perform_bulk_assignment(limit: 1)
expect(first_created.reload.assignee).to eq(agent)
expect(second_created.reload.assignee).to be_nil
end
end
end
end

View File

@@ -1,6 +1,4 @@
require 'rails_helper'
describe AutoAssignment::InboxRoundRobinService do
RSpec.describe AutoAssignment::InboxRoundRobinService do
subject(:inbox_round_robin_service) { described_class.new(inbox: inbox) }
let!(:account) { create(:account) }

View File

@@ -0,0 +1,139 @@
require 'rails_helper'
RSpec.describe AutoAssignment::RateLimiter do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, inbox: inbox) }
let(:rate_limiter) { described_class.new(inbox: inbox, agent: agent) }
describe '#within_limit?' do
context 'when rate limiting is not enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({})
end
it 'returns true' do
expect(rate_limiter.within_limit?).to be true
end
end
context 'when rate limiting is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 5,
'fair_distribution_window' => 3600
})
end
it 'returns true when under the limit' do
allow(rate_limiter).to receive(:current_count).and_return(3)
expect(rate_limiter.within_limit?).to be true
end
it 'returns false when at or over the limit' do
allow(rate_limiter).to receive(:current_count).and_return(5)
expect(rate_limiter.within_limit?).to be false
end
end
end
describe '#track_assignment' do
context 'when rate limiting is not enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({})
end
it 'does not track the assignment' do
expect(Redis::Alfred).not_to receive(:set)
rate_limiter.track_assignment(conversation)
end
end
context 'when rate limiting is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 5,
'fair_distribution_window' => 3600
})
end
it 'creates a Redis key with correct expiry' do
expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation.id}"
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
ex: 3600
)
rate_limiter.track_assignment(conversation)
end
end
end
describe '#current_count' do
context 'when rate limiting is not enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({})
end
it 'returns 0' do
expect(rate_limiter.current_count).to eq(0)
end
end
context 'when rate limiting is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 5,
'fair_distribution_window' => 3600
})
end
it 'counts matching Redis keys' do
pattern = "assignment:#{inbox.id}:agent:#{agent.id}:*"
allow(Redis::Alfred).to receive(:keys_count).with(pattern).and_return(3)
expect(rate_limiter.current_count).to eq(3)
end
end
end
describe 'configuration' do
context 'with custom window' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 10,
'fair_distribution_window' => 7200
})
end
it 'uses the custom window value' do
expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation.id}"
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
ex: 7200
)
rate_limiter.track_assignment(conversation)
end
end
context 'without custom window' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 10
})
end
it 'uses the default window value of 3600' do
expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation.id}"
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
ex: 3600
)
rate_limiter.track_assignment(conversation)
end
end
end
end