mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-04 13:07:55 +00:00 
			
		
		
		
	feat: add assignment service
This commit is contained in:
		
							
								
								
									
										28
									
								
								app/jobs/auto_assignment/assignment_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/jobs/auto_assignment/assignment_job.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										15
									
								
								app/jobs/auto_assignment/periodic_assignment_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/jobs/auto_assignment/periodic_assignment_job.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -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?
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								app/models/concerns/inbox_agent_availability.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/models/concerns/inbox_agent_availability.rb
									
									
									
									
									
										Normal 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')
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										99
									
								
								app/services/auto_assignment/assignment_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								app/services/auto_assignment/assignment_service.rb
									
									
									
									
									
										Normal 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')
 | 
			
		||||
							
								
								
									
										49
									
								
								app/services/auto_assignment/rate_limiter.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								app/services/auto_assignment/rate_limiter.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										16
									
								
								app/services/auto_assignment/round_robin_selector.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/services/auto_assignment/round_robin_selector.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -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'
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								enterprise/app/models/enterprise/assignment_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								enterprise/app/models/enterprise/assignment_policy.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										31
									
								
								enterprise/app/models/enterprise/inbox_agent_availability.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								enterprise/app/models/enterprise/inbox_agent_availability.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										138
									
								
								spec/enterprise/auto_assignment/capacity_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								spec/enterprise/auto_assignment/capacity_service_spec.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -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) }
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
							
								
								
									
										186
									
								
								spec/services/auto_assignment/assignment_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								spec/services/auto_assignment/assignment_service_spec.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -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) }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										139
									
								
								spec/services/auto_assignment/rate_limiter_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								spec/services/auto_assignment/rate_limiter_spec.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
		Reference in New Issue
	
	Block a user