Compare commits

...
Author SHA1 Message Date
Tanmay Sharma 431c36e102 add agent capacity support 2025-08-12 20:10:33 +05:30
Tanmay Sharma b53bd932c0 Merge remote-tracking branch 'origin/develop' into assignment_v2/assignment_policy 2025-08-12 13:48:25 +05:30
Tanmay Sharma c11581b873 Merge remote-tracking branch 'origin/assignment_v2/assignment_policy' into assignment_v2/assignment_policy 2025-08-11 22:59:39 +05:30
Tanmay Sharma 68d08d7b7d fix rate limiting spec 2025-08-11 22:59:10 +05:30
Tanmay Deep SharmaandGitHub fc5d878c00 Merge branch 'assignment_v2/migrations' into assignment_v2/assignment_policy 2025-08-11 22:29:29 +05:30
Tanmay Sharma 048682130c add unique check to inbox in inbox assignment policy 2025-08-11 22:26:36 +05:30
Tanmay Sharma d6310fbd75 fix rspecs 2025-08-11 22:21:03 +05:30
Tanmay Sharma de0aa5bb30 self review 2025-08-11 20:46:17 +05:30
Tanmay Sharma 5871e8e25d remove extra code 2025-08-11 19:34:34 +05:30
Tanmay Sharma a3e74c314a segregate enterprise and base 2025-08-11 16:56:50 +05:30
Tanmay Sharma b84e9ddca2 Merge branch 'assignment_v2/migrations' into wip/assignment-import 2025-08-11 15:27:56 +05:30
Tanmay Sharma 933d310185 assignment_v2: add inbox concerns used by Inbox 2025-08-11 09:09:32 +05:30
Tanmay Sharma 9f1a2b53ae assignment_v2: keep routes minimal for assignment policy only (revert other feature routes) 2025-08-11 09:08:01 +05:30
Tanmay Sharma b2ada112d7 assignment_v2: import assignment policy controllers/models/services/job/policy/specs 2025-08-11 09:06:30 +05:30
Tanmay Sharma 2ebde48d15 add migration files for assignment v2 2025-08-11 08:03:01 +05:30
46 changed files with 2806 additions and 5 deletions
@@ -0,0 +1,79 @@
# frozen_string_literal: true
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
end
def show
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
end
def create
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
if @assignment_policy.save
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @assignment_policy.update(assignment_policy_params)
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
if @assignment_policy.destroy
head :ok
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(params[:id])
end
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
def serialize_assignment_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
assignment_order: policy.assignment_order,
conversation_priority: policy.conversation_priority,
fair_distribution_limit: policy.fair_distribution_limit,
fair_distribution_window: policy.fair_distribution_window,
enabled: policy.enabled,
inbox_count: policy.inboxes.count,
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_assignment_policies(policies)
policies.map { |policy| serialize_assignment_policy(policy) }
end
def check_authorization
authorize(AssignmentPolicy)
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :check_authorization
def show
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}
else
render json: {
inbox_assignment_policy: nil,
message: 'No assignment policy assigned to this inbox'
}
end
end
def create
# Remove existing assignment if any
@inbox.inbox_assignment_policy&.destroy
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
if @inbox_assignment_policy.save
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}, status: :created
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
if @inbox_assignment_policy.destroy
head :ok
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def check_authorization
authorize(@inbox, :update?)
end
def serialize_inbox_assignment_policy(inbox_assignment_policy)
{
id: inbox_assignment_policy.id,
inbox_id: inbox_assignment_policy.inbox_id,
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
assignment_policy: {
id: inbox_assignment_policy.assignment_policy.id,
name: inbox_assignment_policy.assignment_policy.name,
description: inbox_assignment_policy.assignment_policy.description,
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
enabled: inbox_assignment_policy.assignment_policy.enabled
},
created_at: inbox_assignment_policy.created_at,
updated_at: inbox_assignment_policy.updated_at
}
end
end
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentJob < ApplicationJob
queue_as :low
def perform(inbox_id: nil, conversation_id: nil)
if conversation_id
assign_single_conversation(conversation_id)
elsif inbox_id
assign_inbox_conversations(inbox_id)
else
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
end
end
private
def assign_single_conversation(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
service.perform_for_conversation(conversation)
end
def assign_inbox_conversations(inbox_id)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
return unless inbox.assignment_v2_enabled?
service = AssignmentV2::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
end
end
+1
View File
@@ -61,6 +61,7 @@ class Account < ApplicationRecord
has_many :agent_bots, dependent: :destroy_async
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :macros, dependent: :destroy_async
has_many :campaigns, dependent: :destroy_async
+59
View File
@@ -0,0 +1,59 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default("round_robin"), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_assignment_policies_on_account_id (account_id)
# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
# Enums
enum assignment_order: { round_robin: 0 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
# Associations
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
# Validations
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1000 }
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
validates :assignment_order, inclusion: { in: assignment_orders.keys }
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
def webhook_data
{
id: id,
name: name,
description: description,
assignment_order: assignment_order,
conversation_priority: conversation_priority,
fair_distribution_limit: fair_distribution_limit,
fair_distribution_window: fair_distribution_window,
enabled: enabled
}
end
end
AssignmentPolicy.prepend_mod_with('AssignmentPolicy')
@@ -14,11 +14,18 @@ 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.assignment_v2_enabled?
# Use Assignment V2 system
AssignmentV2::AssignmentJob.perform_later(conversation_id: 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?
return false unless inbox.enable_auto_assignment?
# Check auto assignment is enabled (either legacy or v2)
return false unless inbox.auto_assignment_enabled?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
@@ -0,0 +1,57 @@
# frozen_string_literal: true
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents(options = {})
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply rate limiting if assignment policy is enabled
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
scope
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
rate_limiter.within_limits?
end
end
end
+18
View File
@@ -44,6 +44,7 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -72,6 +73,10 @@ class Inbox < ApplicationRecord
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
# Assignment V2 associations
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -184,6 +189,19 @@ class Inbox < ApplicationRecord
members.ids
end
# Assignment V2 methods
def assignment_v2_enabled?
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
end
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
end
end
private
def default_name_for_blank_name
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inbox_assignment_policies
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# assignment_policy_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
# Associations
belongs_to :inbox
belongs_to :assignment_policy
# Validations
validates :inbox_id, uniqueness: true
validate :inbox_belongs_to_same_account
# Delegations
delegate :account, to: :inbox
delegate :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled?,
to: :assignment_policy, prefix: :policy
def webhook_data
{
id: id,
inbox_id: inbox_id,
assignment_policy_id: assignment_policy_id,
policy: assignment_policy.webhook_data
}
end
private
def inbox_belongs_to_same_account
return unless inbox && assignment_policy
return if inbox.account_id == assignment_policy.account_id
errors.add(:inbox, 'must belong to the same account as the assignment policy')
end
end
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class AssignmentPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator?
end
def show?
@account_user.administrator?
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
end
@@ -0,0 +1,102 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless can_assign?(conversation)
agent = find_agent_for_conversation(conversation)
return false unless agent
assign_conversation_to_agent(conversation, agent)
end
def perform_bulk_assignment(limit: 50)
return 0 unless assignment_enabled?
conversations = unassigned_conversations(limit)
assigned_count = 0
conversations.find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def policy
@policy ||= inbox.assignment_policy
end
def assignment_enabled?
policy&.enabled?
end
def can_assign?(conversation)
assignment_enabled? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def find_agent_for_conversation(_conversation)
available_agents = inbox.available_agents(check_rate_limits: true)
if available_agents.empty?
log_no_agents_available
return nil
end
selector_service.select_agent(available_agents)
end
def selector_service
@selector_service ||= AssignmentV2::RoundRobinSelector.new(inbox: inbox)
end
def unassigned_conversations(limit)
scope = inbox.conversations
.unassigned
.open
# Apply conversation priority ordering
scope = case policy.conversation_priority
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
scope.limit(limit)
end
def assign_conversation_to_agent(conversation, agent)
conversation.update!(assignee: agent)
create_assignment_activity(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
false
end
def create_assignment_activity(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def enterprise_enabled?
@enterprise_enabled ||= defined?(Enterprise)
end
def log_no_agents_available
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
end
end
AssignmentV2::AssignmentService.prepend_mod_with('AssignmentV2::AssignmentService')
@@ -0,0 +1,75 @@
# frozen_string_literal: true
# Rate limiter for assignment operations
# Uses SQL to track assignment counts per agent per time window
# based on assignment policy's fair_distribution_limit and fair_distribution_window
class AssignmentV2::RateLimiter
pattr_initialize [:inbox!, :user!]
# Check if the user has exceeded rate limits
# @return [Boolean] true if within limits, false if exceeded
def within_limits?
return true unless policy_exists?
current_count < rate_limit
end
# Get current rate limit status for the user
# @return [Hash] Rate limit status information
def status
if policy_exists?
{
within_limits: within_limits?,
current_count: current_count,
limit: rate_limit,
reset_at: Time.zone.at(next_window_start)
}
else
{
within_limits: true,
current_count: 0,
limit: Float::INFINITY,
reset_at: nil
}
end
end
private
def policy
@policy ||= inbox.assignment_policy
end
def policy_exists?
policy.present? && policy.enabled?
end
def current_count
# Count conversations assigned to this user in the current time window
# from this inbox
window_start = Time.zone.at(current_window)
Conversation
.where(inbox_id: inbox.id)
.where(assignee_id: user.id)
.where('updated_at >= ?', window_start)
.where.not(assignee_id: nil)
.count
end
def rate_limit
policy&.fair_distribution_limit || 10
end
def time_window
policy&.fair_distribution_window || 3600
end
def current_window
(Time.current.to_i / time_window) * time_window
end
def next_window_start
current_window + time_window
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class AssignmentV2::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Extract user IDs from inbox members
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
# Use Redis queue for round robin
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
return nil unless selected_user_id
# Return the user object
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
end
def add_agent_to_queue(user_id)
round_robin_service.add_agent_to_queue(user_id)
end
def remove_agent_from_queue(user_id)
round_robin_service.remove_agent_from_queue(user_id)
end
def reset_queue
round_robin_service.reset_queue
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end
+3
View File
@@ -191,3 +191,6 @@
display_name: CRM V2
enabled: false
chatwoot_internal: true
- name: assignment_v2
display_name: Assignment V2
enabled: false
+17
View File
@@ -50,6 +50,9 @@ Rails.application.routes.draw do
resource :bulk_actions, only: [:create]
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
member do
get 'capacity', to: 'agents/capacity#show'
end
end
namespace :captain do
resources :assistants do
@@ -97,6 +100,13 @@ Rails.application.routes.draw do
end
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
resources :custom_roles, only: [:index, :create, :show, :update, :destroy]
resources :agent_capacity_policies, only: [:index, :create, :show, :update, :destroy] do
member do
post 'users', to: 'agent_capacity_policies#assign_user'
delete 'users/:user_id', to: 'agent_capacity_policies#unassign_user'
put 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#update_inbox_limit'
end
end
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
namespace :channels do
@@ -217,6 +227,13 @@ Rails.application.routes.draw do
end
end
# Assignment V2 Routes
resources :assignment_policies
resources :inboxes, only: [] do
resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies'
end
namespace :twitter do
resource :authorization, only: [:create]
end
+1
View File
@@ -320,6 +320,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.jsonb "metadata", default: {}
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
@@ -0,0 +1,58 @@
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :fetch_policy, only: [:show, :update, :destroy, :assign_user, :unassign_user, :update_inbox_limit]
before_action :check_enterprise_authorization
def index
@agent_capacity_policies = Current.account.agent_capacity_policies
end
def show; end
def create
@agent_capacity_policy = Current.account.agent_capacity_policies.create!(permitted_params)
end
def update
@agent_capacity_policy.update!(permitted_params)
end
def destroy
@agent_capacity_policy.destroy!
head :ok
end
def assign_user
user = Current.account.users.find(params[:user_id])
account_user = Current.account.account_users.find_by!(user: user)
account_user.update!(agent_capacity_policy: @agent_capacity_policy)
render json: { message: 'User assigned successfully' }
end
def unassign_user
user = Current.account.users.find(params[:user_id])
account_user = Current.account.account_users.find_by!(user: user)
account_user.update!(agent_capacity_policy: nil)
render json: { message: 'User unassigned successfully' }
end
def update_inbox_limit
inbox = Current.account.inboxes.find(params[:inbox_id])
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
inbox_limit.update!(conversation_limit: params[:conversation_limit])
render json: inbox_limit
end
private
def permitted_params
params.require(:agent_capacity_policy).permit(:name, :description, exclusion_rules: {})
end
def fetch_policy
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:id])
end
def check_enterprise_authorization
authorize(Enterprise::AgentCapacityPolicy)
end
end
@@ -0,0 +1,39 @@
class Api::V1::Accounts::Agents::CapacityController < Api::V1::Accounts::EnterpriseAccountsController
before_action :fetch_agent
def show
account_user = Current.account.account_users.find_by!(user: @agent)
capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user)
inbox = fetch_inbox
render json: build_capacity_response(account_user, capacity_service, inbox)
end
private
def fetch_agent
@agent = Current.account.users.find(params[:id])
end
def fetch_inbox
return if params[:inbox_id].blank?
Current.account.inboxes.find(params[:inbox_id])
end
def build_capacity_response(account_user, capacity_service, inbox)
response = {
has_capacity: capacity_service.agent_has_capacity?(inbox),
overall_capacity: capacity_service.agent_overall_capacity,
inbox_capacity: inbox ? capacity_service.agent_capacity_for_inbox(inbox) : nil,
current_conversations_count: account_user.user.assigned_conversations.open.count
}
add_inbox_conversations_count(response, account_user, inbox) if inbox
response
end
def add_inbox_conversations_count(response, account_user, inbox)
response[:inbox_conversations_count] = account_user.user.assigned_conversations.open.where(inbox: inbox).count
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class Enterprise::AgentCapacityPolicy < ApplicationRecord
self.table_name = 'agent_capacity_policies'
belongs_to :account, class_name: '::Account'
has_many :inbox_capacity_limits, class_name: 'Enterprise::InboxCapacityLimit', dependent: :destroy
has_many :inboxes, through: :inbox_capacity_limits, class_name: '::Inbox'
has_many :account_users, class_name: '::AccountUser', dependent: :nullify
validates :name, presence: true, length: { maximum: 255 }
def applicable_for_time?(time = Time.current)
return true if exclusion_rules.blank?
!excluded_for_time?(time)
end
def capacity_for_inbox(inbox)
inbox_capacity_limits.find_by(inbox: inbox)&.conversation_limit
end
def overall_capacity
exclusion_rules['overall_capacity'] || Float::INFINITY
end
private
def excluded_for_time?(time)
excluded_by_hours?(time) || excluded_by_days?(time)
end
def excluded_by_hours?(time)
return false if exclusion_rules['hours'].blank?
current_hour = time.hour
exclusion_rules['hours'].include?(current_hour)
end
def excluded_by_days?(time)
return false if exclusion_rules['days'].blank?
current_day = time.strftime('%A').downcase
exclusion_rules['days'].include?(current_day)
end
end
@@ -0,0 +1,27 @@
module Enterprise::AssignmentPolicy
# In enterprise, we extend the enum to include balanced
# However, since Rails enums are frozen after definition,
# we need to handle this differently
# Override assignment_order= to accept 'balanced'
def assignment_order=(value)
if value.to_s == 'balanced'
write_attribute(:assignment_order, 1)
else
super
end
end
# Override assignment_order getter to return 'balanced' for value 1
def assignment_order
value = read_attribute(:assignment_order)
return 'balanced' if value == 1
super
end
# Define balanced? method
def balanced?
self[:assignment_order] == 1
end
end
@@ -5,6 +5,7 @@ module Enterprise::Concerns::Account
has_many :sla_policies, dependent: :destroy_async
has_many :applied_slas, dependent: :destroy_async
has_many :custom_roles, dependent: :destroy_async
has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy'
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
@@ -3,5 +3,6 @@ module Enterprise::Concerns::AccountUser
included do
belongs_to :custom_role, optional: true
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Enterprise::Concerns::InboxAgentAvailability
def apply_agent_filters(scope, options)
scope = super(scope, options)
# Apply capacity filtering if requested
scope = filter_by_capacity(scope) if options[:check_capacity]
scope
end
private
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
account_user = AccountUser.find_by(account: account, user: inbox_member.user)
next true if account_user&.agent_capacity_policy.blank?
capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user)
capacity_service.agent_has_capacity?(self)
end
end
end
+41 -3
View File
@@ -2,9 +2,17 @@ module Enterprise::Inbox
def member_ids_with_assignment_capacity
return super unless enable_auto_assignment?
max_assignment_limit = auto_assignment_config['max_assignment_limit']
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
super - overloaded_agent_ids
member_ids = apply_max_assignment_limit(super)
apply_capacity_policy_filter(member_ids)
end
def available_agents(options = {})
agents = super(options)
# Apply capacity filtering if requested and assignment policy is enabled
agents = filter_agents_by_capacity(agents) if options[:check_capacity] && assignment_policy&.enabled?
agents
end
def active_bot?
@@ -17,6 +25,36 @@ module Enterprise::Inbox
private
def filter_agents_by_capacity(inbox_members_scope)
inbox_members_scope.select do |inbox_member|
account_user = AccountUser.find_by(account: account, user: inbox_member.user)
next true if account_user&.agent_capacity_policy.blank?
capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user)
capacity_service.agent_has_capacity?(self)
end
end
def apply_max_assignment_limit(member_ids)
max_assignment_limit = auto_assignment_config['max_assignment_limit']
return member_ids if max_assignment_limit.blank?
overloaded_agent_ids = get_agent_ids_over_assignment_limit(max_assignment_limit)
member_ids - overloaded_agent_ids
end
def apply_capacity_policy_filter(member_ids)
return member_ids unless assignment_policy&.enabled?
account_users = AccountUser.where(account_id: account_id, user_id: member_ids)
account_users.select do |account_user|
next true if account_user.agent_capacity_policy.blank?
capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user)
capacity_service.agent_has_capacity?(self)
end.map(&:user_id)
end
def more_responses?
account.usage_limits[:captain][:responses][:current_available].positive?
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class Enterprise::InboxCapacityLimit < ApplicationRecord
self.table_name = 'inbox_capacity_limits'
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
belongs_to :inbox, class_name: '::Inbox'
validates :conversation_limit, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id }
end
@@ -0,0 +1,33 @@
class Enterprise::AgentCapacityPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account_user.agent?
end
def update?
@account_user.administrator?
end
def show?
@account_user.administrator? || @account_user.agent?
end
def create?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
def assign_user?
@account_user.administrator?
end
def unassign_user?
@account_user.administrator?
end
def update_inbox_limit?
@account_user.administrator?
end
end
@@ -0,0 +1,25 @@
module Enterprise::AssignmentV2::AssignmentService
# Override selector_service to use BalancedSelector when appropriate
def selector_service
@selector_service ||= if policy&.balanced?
Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
else
super
end
end
# Override find_agent_for_conversation to include capacity checks
def find_agent_for_conversation(_conversation)
available_agents = inbox.available_agents(
check_rate_limits: true,
check_capacity: true
)
if available_agents.empty?
log_no_agents_available
return nil
end
selector_service.select_agent(available_agents)
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
class Enterprise::AssignmentV2::BalancedSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Get current assignment counts for all available agents
agent_users = available_agents.map(&:user)
assignment_counts = fetch_assignment_counts(agent_users)
# Find the agent with the least assignments
selected_agent = agent_users.min_by { |user| assignment_counts[user.id] || 0 }
# Log the selection for debugging
Rails.logger.info "BalancedSelector: Selected agent #{selected_agent.id} with #{assignment_counts[selected_agent.id] || 0} assignments"
selected_agent
end
def add_agent_to_queue(user_id)
# No-op for balanced assignment - we don't maintain a queue
end
def remove_agent_from_queue(user_id)
# No-op for balanced assignment - we don't maintain a queue
end
def reset_queue
# No-op for balanced assignment - we don't maintain a queue
end
private
def fetch_assignment_counts(users)
# Get open conversation counts for each user
user_ids = users.map(&:id)
# Count open conversations assigned to each user in this inbox
counts = inbox.conversations
.open
.where(assignee_id: user_ids)
.group(:assignee_id)
.count
# Convert to hash with default value of 0
Hash.new(0).merge(counts)
end
def account
@account ||= inbox.account
end
end
@@ -0,0 +1,76 @@
# frozen_string_literal: true
class Enterprise::AssignmentV2::CapacityService
def initialize(account_user)
@account_user = account_user
@account = account_user.account
end
def agent_has_capacity?(inbox = nil)
return true unless capacity_policy_applicable?
if inbox
check_inbox_capacity(inbox)
else
check_overall_capacity
end
end
def agent_capacity_for_inbox(inbox)
return Float::INFINITY unless capacity_policy_applicable?
policy = @account_user.agent_capacity_policy
inbox_limit = policy.capacity_for_inbox(inbox)
return Float::INFINITY unless inbox_limit
current_count = current_conversations_count(inbox)
[inbox_limit - current_count, 0].max
end
def agent_overall_capacity
return Float::INFINITY unless capacity_policy_applicable?
policy = @account_user.agent_capacity_policy
overall_limit = policy.overall_capacity
return Float::INFINITY if overall_limit == Float::INFINITY
current_count = current_conversations_count
[overall_limit - current_count, 0].max
end
def current_conversations_count(inbox = nil)
scope = @account_user.user.conversations
.joins(:account)
.where(account: @account, status: :open)
scope = scope.where(inbox: inbox) if inbox
scope.count
end
private
def capacity_policy_applicable?
return false if @account_user.agent_capacity_policy.blank?
@account_user.agent_capacity_policy.applicable_for_time?
end
def check_inbox_capacity(inbox)
policy = @account_user.agent_capacity_policy
inbox_limit = policy.capacity_for_inbox(inbox)
return check_overall_capacity unless inbox_limit
current_count = current_conversations_count(inbox)
current_count < inbox_limit && check_overall_capacity
end
def check_overall_capacity
policy = @account_user.agent_capacity_policy
overall_limit = policy.overall_capacity
return true if overall_limit == Float::INFINITY
current_count = current_conversations_count
current_count < overall_limit
end
end
@@ -0,0 +1,12 @@
json.id @agent_capacity_policy.id
json.name @agent_capacity_policy.name
json.description @agent_capacity_policy.description
json.exclusion_rules @agent_capacity_policy.exclusion_rules
json.created_at @agent_capacity_policy.created_at
json.updated_at @agent_capacity_policy.updated_at
json.account_id @agent_capacity_policy.account_id
json.inbox_capacity_limits @agent_capacity_policy.inbox_capacity_limits do |limit|
json.id limit.id
json.inbox_id limit.inbox_id
json.conversation_limit limit.conversation_limit
end
@@ -0,0 +1,14 @@
json.array! @agent_capacity_policies do |policy|
json.id policy.id
json.name policy.name
json.description policy.description
json.exclusion_rules policy.exclusion_rules
json.created_at policy.created_at
json.updated_at policy.updated_at
json.account_id policy.account_id
json.inbox_capacity_limits policy.inbox_capacity_limits do |limit|
json.id limit.id
json.inbox_id limit.inbox_id
json.conversation_limit limit.conversation_limit
end
end
@@ -0,0 +1,12 @@
json.id @agent_capacity_policy.id
json.name @agent_capacity_policy.name
json.description @agent_capacity_policy.description
json.exclusion_rules @agent_capacity_policy.exclusion_rules
json.created_at @agent_capacity_policy.created_at
json.updated_at @agent_capacity_policy.updated_at
json.account_id @agent_capacity_policy.account_id
json.inbox_capacity_limits @agent_capacity_policy.inbox_capacity_limits do |limit|
json.id limit.id
json.inbox_id limit.inbox_id
json.conversation_limit limit.conversation_limit
end
@@ -0,0 +1,12 @@
json.id @agent_capacity_policy.id
json.name @agent_capacity_policy.name
json.description @agent_capacity_policy.description
json.exclusion_rules @agent_capacity_policy.exclusion_rules
json.created_at @agent_capacity_policy.created_at
json.updated_at @agent_capacity_policy.updated_at
json.account_id @agent_capacity_policy.account_id
json.inbox_capacity_limits @agent_capacity_policy.inbox_capacity_limits do |limit|
json.id limit.id
json.inbox_id limit.inbox_id
json.conversation_limit limit.conversation_limit
end
@@ -0,0 +1,128 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Enterprise::AgentCapacityPolicy do
let(:account) { create(:account) }
let(:policy) { described_class.create!(account: account, name: 'Test Policy') }
describe 'associations' do
it 'belongs to account' do
expect(policy.account).to eq(account)
end
it 'has many inbox capacity limits' do
expect(policy).to respond_to(:inbox_capacity_limits)
end
it 'has many inboxes through inbox capacity limits' do
expect(policy).to respond_to(:inboxes)
end
it 'has many account users' do
expect(policy).to respond_to(:account_users)
end
end
describe 'validations' do
it 'validates presence of name' do
invalid_policy = described_class.new(account: account)
expect(invalid_policy).not_to be_valid
expect(invalid_policy.errors[:name]).to include("can't be blank")
end
it 'validates length of name' do
invalid_policy = described_class.new(account: account, name: 'a' * 256)
expect(invalid_policy).not_to be_valid
expect(invalid_policy.errors[:name]).to include('is too long (maximum is 255 characters)')
end
end
describe '#applicable_for_time?' do
context 'when no exclusion rules' do
it 'returns true' do
expect(policy.applicable_for_time?).to be true
end
end
context 'with hour exclusions' do
let(:policy) do
described_class.create!(
account: account,
name: 'Hour Exclusion Policy',
exclusion_rules: { 'hours' => [10, 11, 12] }
)
end
it 'returns false during excluded hours' do
time = Time.zone.parse('10:30')
expect(policy.applicable_for_time?(time)).to be false
end
it 'returns true outside excluded hours' do
time = Time.zone.parse('13:30')
expect(policy.applicable_for_time?(time)).to be true
end
end
context 'with day exclusions' do
let(:policy) do
described_class.create!(
account: account,
name: 'Day Exclusion Policy',
exclusion_rules: { 'days' => %w[saturday sunday] }
)
end
it 'returns false on excluded days' do
time = Time.zone.parse('2024-01-06 10:00') # Saturday
expect(policy.applicable_for_time?(time)).to be false
end
it 'returns true on non-excluded days' do
time = Time.zone.parse('2024-01-08 10:00') # Monday
expect(policy.applicable_for_time?(time)).to be true
end
end
end
describe '#capacity_for_inbox' do
let(:inbox) { create(:inbox, account: account) }
it 'returns the conversation limit for the inbox' do
Enterprise::InboxCapacityLimit.create!(
agent_capacity_policy: policy,
inbox: inbox,
conversation_limit: 10
)
expect(policy.capacity_for_inbox(inbox)).to eq(10)
end
it 'returns nil for inbox without limit' do
other_inbox = create(:inbox, account: account)
expect(policy.capacity_for_inbox(other_inbox)).to be_nil
end
end
describe '#overall_capacity' do
context 'when overall_capacity is set' do
let(:policy) do
described_class.create!(
account: account,
name: 'Overall Capacity Policy',
exclusion_rules: { 'overall_capacity' => 25 }
)
end
it 'returns the overall capacity' do
expect(policy.overall_capacity).to eq(25)
end
end
context 'when overall_capacity is not set' do
it 'returns infinity' do
expect(policy.overall_capacity).to eq(Float::INFINITY)
end
end
end
end
@@ -0,0 +1,141 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Assignment with Capacity' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
let(:agent1) { create(:user, accounts: [account]) }
let(:agent2) { create(:user, accounts: [account]) }
let(:account_user1) { AccountUser.find_by(account: account, user: agent1) }
let(:account_user2) { AccountUser.find_by(account: account, user: agent2) }
let(:capacity_policy) do
Enterprise::AgentCapacityPolicy.create!(
account: account,
name: 'Test Capacity Policy',
exclusion_rules: { 'overall_capacity' => 5 }
)
end
let!(:inbox_capacity_limit) do
Enterprise::InboxCapacityLimit.create!(
agent_capacity_policy: capacity_policy,
inbox: inbox,
conversation_limit: 3
)
end
before do
# Create and setup assignment policy for the inbox
@assignment_policy = create(:assignment_policy, account: account, enabled: true)
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: @assignment_policy)
# Add agents to inbox
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
# Set agents online using presence and status
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
# Also set account_user availability
account_user1.update!(availability: 'online')
account_user2.update!(availability: 'online')
# Assign capacity policy to agent1 only
account_user1.update!(agent_capacity_policy: capacity_policy)
end
describe 'capacity-based agent filtering' do
context 'when agent has capacity' do
it 'includes agent in available agents' do
available_agents = inbox.available_agents(check_capacity: true)
expect(available_agents.map(&:user)).to include(agent1, agent2)
end
end
context 'when agent reaches inbox capacity limit' do
before do
# Create 3 conversations for agent1 (at inbox limit)
create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent1, status: :open)
end
it 'excludes agent from available agents for that inbox' do
available_agents = inbox.available_agents(check_capacity: true)
expect(available_agents.map(&:user)).not_to include(agent1)
expect(available_agents.map(&:user)).to include(agent2)
end
end
context 'when agent reaches overall capacity limit' do
before do
# Create 5 conversations for agent1 (at overall limit)
create_list(:conversation, 5, account: account, assignee: agent1, status: :open)
end
it 'excludes agent from all inbox assignments' do
available_agents = inbox.available_agents(check_capacity: true)
expect(available_agents.map(&:user)).not_to include(agent1)
expect(available_agents.map(&:user)).to include(agent2)
end
end
context 'when capacity policy is not applicable (time exclusion)' do
before do
capacity_policy.update!(exclusion_rules: {
'overall_capacity' => 5,
'hours' => [Time.current.hour]
})
# Create 5 conversations for agent1 (would be at limit if policy was active)
create_list(:conversation, 5, account: account, assignee: agent1, status: :open)
end
it 'includes agent in available agents' do
available_agents = inbox.available_agents(check_capacity: true)
expect(available_agents.map(&:user)).to include(agent1, agent2)
end
end
end
describe 'capacity-aware assignment' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :open, assignee: nil) }
context 'when agents have capacity' do
it 'both agents are available for assignment' do
available = inbox.available_agents(check_capacity: true)
expect(available.map(&:user)).to include(agent1, agent2)
end
end
context 'when one agent is at capacity' do
before do
# Agent1 at inbox capacity
create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent1, status: :open)
end
it 'only agent with capacity is available' do
available = inbox.available_agents(check_capacity: true)
expect(available.map(&:user)).not_to include(agent1)
expect(available.map(&:user)).to include(agent2)
end
end
context 'when all agents are at capacity' do
before do
# Both agents at capacity
account_user2.update!(agent_capacity_policy: capacity_policy)
create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent1, status: :open)
create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent2, status: :open)
end
it 'no agents are available' do
available = inbox.available_agents(check_capacity: true)
expect(available).to be_empty
end
end
end
end
@@ -0,0 +1,168 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Enterprise::AssignmentV2::CapacityService do
let(:account) { create(:account) }
let(:user) { create(:user, accounts: [account]) }
let(:account_user) { AccountUser.find_by(account: account, user: user) }
let(:inbox) { create(:inbox, account: account) }
let(:service) { described_class.new(account_user) }
describe '#agent_has_capacity?' do
context 'without capacity policy' do
it 'returns true' do
expect(service.agent_has_capacity?).to be true
expect(service.agent_has_capacity?(inbox)).to be true
end
end
context 'with capacity policy' do
let(:policy) { Enterprise::AgentCapacityPolicy.create!(account: account, name: 'Test Policy') }
before do
account_user.update!(agent_capacity_policy: policy)
end
context 'when policy is not applicable' do
before do
allow(policy).to receive(:applicable_for_time?).and_return(false)
end
it 'returns true' do
expect(service.agent_has_capacity?).to be true
end
end
context 'when checking inbox capacity' do
let!(:inbox_limit) do
Enterprise::InboxCapacityLimit.create!(
agent_capacity_policy: policy,
inbox: inbox,
conversation_limit: 5
)
end
it 'returns true when under limit' do
create_list(:conversation, 3, account: account, inbox: inbox, assignee: user, status: :open)
expect(service.agent_has_capacity?(inbox)).to be true
end
it 'returns false when at limit' do
create_list(:conversation, 5, account: account, inbox: inbox, assignee: user, status: :open)
expect(service.agent_has_capacity?(inbox)).to be false
end
it 'checks overall capacity too' do
policy.update!(exclusion_rules: { 'overall_capacity' => 10 })
create_list(:conversation, 9, account: account, assignee: user, status: :open)
# Under inbox limit but close to overall limit
expect(service.agent_has_capacity?(inbox)).to be true
# Add one more to hit overall limit
create(:conversation, account: account, assignee: user, status: :open)
expect(service.agent_has_capacity?(inbox)).to be false
end
end
context 'when checking overall capacity' do
before do
policy.update!(exclusion_rules: { 'overall_capacity' => 10 })
end
it 'returns true when under limit' do
create_list(:conversation, 8, account: account, assignee: user, status: :open)
expect(service.agent_has_capacity?).to be true
end
it 'returns false when at limit' do
create_list(:conversation, 10, account: account, assignee: user, status: :open)
expect(service.agent_has_capacity?).to be false
end
end
end
end
describe '#agent_capacity_for_inbox' do
context 'without capacity policy' do
it 'returns infinity' do
expect(service.agent_capacity_for_inbox(inbox)).to eq(Float::INFINITY)
end
end
context 'with capacity policy and inbox limit' do
let(:policy) { Enterprise::AgentCapacityPolicy.create!(account: account, name: 'Test Policy') }
let!(:inbox_limit) do
Enterprise::InboxCapacityLimit.create!(
agent_capacity_policy: policy,
inbox: inbox,
conversation_limit: 5
)
end
before do
account_user.update!(agent_capacity_policy: policy)
end
it 'returns remaining capacity' do
create_list(:conversation, 2, account: account, inbox: inbox, assignee: user, status: :open)
expect(service.agent_capacity_for_inbox(inbox)).to eq(3)
end
it 'returns 0 when at capacity' do
create_list(:conversation, 5, account: account, inbox: inbox, assignee: user, status: :open)
expect(service.agent_capacity_for_inbox(inbox)).to eq(0)
end
end
end
describe '#agent_overall_capacity' do
context 'without capacity policy' do
it 'returns infinity' do
expect(service.agent_overall_capacity).to eq(Float::INFINITY)
end
end
context 'with capacity policy and overall limit' do
let(:policy) do
Enterprise::AgentCapacityPolicy.create!(
account: account,
name: 'Overall Policy',
exclusion_rules: { 'overall_capacity' => 10 }
)
end
before do
account_user.update!(agent_capacity_policy: policy)
end
it 'returns remaining capacity' do
create_list(:conversation, 6, account: account, assignee: user, status: :open)
expect(service.agent_overall_capacity).to eq(4)
end
it 'returns 0 when at capacity' do
create_list(:conversation, 10, account: account, assignee: user, status: :open)
expect(service.agent_overall_capacity).to eq(0)
end
end
end
describe '#current_conversations_count' do
it 'counts open conversations assigned to user' do
create_list(:conversation, 3, account: account, assignee: user, status: :open)
create(:conversation, account: account, assignee: user, status: :resolved)
create(:conversation, account: account, status: :open)
expect(service.current_conversations_count).to eq(3)
end
it 'counts inbox-specific conversations when inbox provided' do
create_list(:conversation, 2, account: account, inbox: inbox, assignee: user, status: :open)
create(:conversation, account: account, assignee: user, status: :open)
expect(service.current_conversations_count(inbox)).to eq(2)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :agent_capacity_policy, class: 'Enterprise::AgentCapacityPolicy' do
account
name { Faker::Name.name }
description { Faker::Lorem.sentence }
exclusion_rules { {} }
end
end
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
FactoryBot.define do
factory :assignment_policy do
account
sequence(:name) { |n| "Assignment Policy #{n}" }
description { 'Test assignment policy' }
assignment_order { :round_robin }
conversation_priority { :earliest_created }
fair_distribution_limit { 10 }
fair_distribution_window { 3600 }
enabled { true }
trait :balanced do
assignment_order { :balanced }
end
trait :disabled do
enabled { false }
end
trait :longest_waiting do
conversation_priority { :longest_waiting }
end
trait :with_high_limit do
fair_distribution_limit { 50 }
end
trait :with_short_window do
fair_distribution_window { 300 } # 5 minutes
end
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
factory :inbox_assignment_policy do
inbox
assignment_policy
# Ensure inbox and policy belong to same account
after(:build) do |inbox_policy|
inbox_policy.assignment_policy.account = inbox_policy.inbox.account if inbox_policy.inbox && inbox_policy.assignment_policy
end
end
end
+9
View File
@@ -0,0 +1,9 @@
# frozen_string_literal: true
FactoryBot.define do
factory :inbox_capacity_limit, class: 'Enterprise::InboxCapacityLimit' do
association :agent_capacity_policy, factory: :agent_capacity_policy
inbox
conversation_limit { 10 }
end
end
@@ -0,0 +1,196 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentJob, type: :job do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe '#perform' do
context 'with conversation_id' do
it 'assigns a single conversation' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
described_class.new.perform(conversation_id: conversation.id)
end
it 'handles non-existent conversation gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(conversation_id: 999_999)
end.not_to raise_error
end
end
context 'with inbox_id' do
let!(:agent) { create(:user, account: account, role: :agent, availability: :online) }
before do
create_list(:conversation, 3, inbox: inbox, assignee: nil)
create(:inbox_member, inbox: inbox, user: agent)
end
it 'assigns multiple conversations for inbox' do
# Mock the feature flag for assignment_v2
allow(inbox.account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).and_return(3)
described_class.new.perform(inbox_id: inbox.id)
end
it 'logs the number of assigned conversations' do
# Mock the feature flag for assignment_v2
allow(inbox.account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(2)
expect(Rails.logger).to receive(:info).with("AssignmentV2::AssignmentJob: Assigned 2 conversations for inbox #{inbox.id}")
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when inbox has no policy' do
inbox_assignment_policy.destroy!
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when policy is disabled' do
assignment_policy.update!(enabled: false)
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'handles non-existent inbox gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(inbox_id: 999_999)
end.not_to raise_error
end
end
context 'without parameters' do
it 'logs error when no parameters provided' do
expect(Rails.logger).to receive(:error).with('AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided')
described_class.new.perform
end
it 'does not attempt assignment' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform
end
end
context 'with both parameters' do
it 'prioritizes conversation_id over inbox_id' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
expect(service).not_to receive(:perform_bulk_assignment)
described_class.new.perform(conversation_id: conversation.id, inbox_id: inbox.id)
end
end
end
describe 'job configuration' do
it 'uses the low queue' do
expect(described_class.new.queue_name).to eq('low')
end
end
describe 'error handling' do
context 'when assignment service raises error' do
it 'propagates the error for retry' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_for_conversation).and_raise(StandardError, 'Assignment failed')
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(StandardError, 'Assignment failed')
end
end
context 'when database connection fails' do
it 'raises error for retry' do
allow(Conversation).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished)
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(ActiveRecord::ConnectionNotEstablished)
end
end
end
describe 'concurrency and idempotency' do
it 'handles concurrent job execution safely' do
# Create multiple jobs for same inbox
jobs = []
3.times { jobs << described_class.new }
# All should execute without issues
expect do
jobs.each { |job| job.perform(inbox_id: inbox.id) }
end.not_to raise_error
end
it 'is idempotent for conversation assignment' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# First call assigns
expect(service).to receive(:perform_for_conversation).and_return(true)
described_class.new.perform(conversation_id: conversation.id)
# Second call should handle already assigned conversation
expect(service).to receive(:perform_for_conversation).and_return(false)
expect { described_class.new.perform(conversation_id: conversation.id) }.not_to raise_error
end
end
describe 'performance considerations' do
it 'processes large inbox assignments in batches' do
# Create many unassigned conversations
create_list(:conversation, 100, inbox: inbox, assignee: nil)
# Mock the feature flag for assignment_v2
allow(inbox.account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# Service should be called with default limit
expect(service).to receive(:perform_bulk_assignment).with(no_args).and_return(50)
described_class.new.perform(inbox_id: inbox.id)
end
end
end
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
end
describe 'validations' do
subject { assignment_policy }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
it { is_expected.to validate_presence_of(:fair_distribution_limit) }
it { is_expected.to validate_numericality_of(:fair_distribution_limit).is_greater_than(0).is_less_than_or_equal_to(100) }
it { is_expected.to validate_presence_of(:fair_distribution_window) }
it { is_expected.to validate_numericality_of(:fair_distribution_window).is_greater_than(60).is_less_than_or_equal_to(86_400) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0) }
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = assignment_policy.webhook_data
expect(data).to include(
id: assignment_policy.id,
name: assignment_policy.name,
description: assignment_policy.description,
assignment_order: assignment_policy.assignment_order,
conversation_priority: assignment_policy.conversation_priority,
fair_distribution_limit: assignment_policy.fair_distribution_limit,
fair_distribution_window: assignment_policy.fair_distribution_window,
enabled: assignment_policy.enabled
)
end
end
end
+116
View File
@@ -0,0 +1,116 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InboxAssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe 'associations' do
it { is_expected.to belong_to(:inbox) }
it { is_expected.to belong_to(:assignment_policy) }
end
describe 'validations' do
subject { inbox_assignment_policy }
it { is_expected.to validate_uniqueness_of(:inbox_id) }
context 'with inbox and policy from different accounts' do
let(:other_account) { create(:account) }
let(:other_policy) { create(:assignment_policy, account: other_account) }
it 'validates inbox belongs to same account as policy' do
# Build without the factory callback that sets the accounts to be the same
invalid_policy = described_class.new(inbox: inbox, assignment_policy: other_policy)
expect(invalid_policy).not_to be_valid
expect(invalid_policy.errors[:inbox]).to include('must belong to the same account as the assignment policy')
end
end
end
describe 'delegations' do
it 'delegates account to inbox' do
expect(inbox_assignment_policy.account).to eq(account)
end
it 'delegates policy attributes' do
expect(inbox_assignment_policy.policy_name).to eq(assignment_policy.name)
expect(inbox_assignment_policy.policy_description).to eq(assignment_policy.description)
expect(inbox_assignment_policy.policy_assignment_order).to eq(assignment_policy.assignment_order)
expect(inbox_assignment_policy.policy_conversation_priority).to eq(assignment_policy.conversation_priority)
expect(inbox_assignment_policy.policy_fair_distribution_limit).to eq(assignment_policy.fair_distribution_limit)
expect(inbox_assignment_policy.policy_fair_distribution_window).to eq(assignment_policy.fair_distribution_window)
expect(inbox_assignment_policy.policy_enabled?).to eq(assignment_policy.enabled?)
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = inbox_assignment_policy.webhook_data
expect(data).to include(
id: inbox_assignment_policy.id,
inbox_id: inbox.id,
assignment_policy_id: assignment_policy.id
)
expect(data[:policy]).to eq(assignment_policy.webhook_data)
end
end
describe 'business logic constraints' do
it 'prevents multiple policies per inbox' do
# Ensure first policy exists
inbox_assignment_policy
policy2 = create(:assignment_policy, account: account)
# Try to create a second policy for the same inbox
duplicate_policy = described_class.new(inbox: inbox, assignment_policy: policy2)
expect(duplicate_policy).not_to be_valid
expect(duplicate_policy.errors[:inbox_id]).to include('has already been taken')
end
it 'allows reassigning to different policy' do
policy2 = create(:assignment_policy, account: account)
expect do
inbox_assignment_policy.update!(assignment_policy: policy2)
end.not_to raise_error
expect(inbox_assignment_policy.reload.assignment_policy).to eq(policy2)
end
end
describe 'edge cases' do
it 'handles nil associations gracefully' do
# Build without saving to test nil handling
policy = build(:inbox_assignment_policy, inbox: nil, assignment_policy: nil)
expect { policy.valid? }.not_to raise_error
expect(policy).not_to be_valid
end
it 'handles policy deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting policy should delete inbox assignment
assignment_policy.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
it 'handles inbox deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting inbox should delete inbox assignment
inbox.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
end
end
@@ -0,0 +1,329 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentService do
before do
# Mock the GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
# Define the constant if not already defined
stub_const('ASSIGNEE_CHANGED', 'assignee.changed') unless defined?(ASSIGNEE_CHANGED)
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
create(:inbox_member, inbox: inbox, user: agent3)
# Mock available agents to return inbox members
online_members = InboxMember.joins(:user).where(inbox: inbox, user: [agent1, agent2])
allow(inbox).to receive(:available_agents).and_return(online_members)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
let(:service) { described_class.new(inbox: inbox) }
# Create agents
let!(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent3) { create(:user, account: account, role: :agent, availability: :offline) }
# Make agents members of inbox
describe '#perform_for_conversation' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
context 'when policy is enabled' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'assigns conversation to an available agent' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'dispatches assignment event' do
# The dispatcher is called from the assignment service and also from conversation model
allow(Rails.configuration.dispatcher).to receive(:dispatch)
service.perform_for_conversation(conversation)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'assignee.changed',
anything,
hash_including(conversation: conversation, user: agent1)
).at_least(:once)
end
it 'returns false when no agents are available' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
allow(Rails.logger).to receive(:warn)
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when conversation is already assigned' do
before { conversation.update!(assignee: agent1) }
it 'does not reassign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to eq(agent1)
end
end
context 'with round robin assignment' do
before do
assignment_policy.update!(assignment_order: :round_robin)
# Mock round robin selector to return agents in rotation
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
agent_index = 0
allow(selector).to receive(:select_agent) do
agent = [agent1, agent2][agent_index % 2]
agent_index += 1
agent
end
end
it 'assigns agents in rotation' do
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
assignments = conversations.map do |conv|
service.perform_for_conversation(conv)
conv.reload.assignee
end
# Should rotate between available agents
expect(assignments[0]).to eq(agent1)
expect(assignments[1]).to eq(agent2)
expect(assignments[2]).to eq(agent1) # Back to first agent
expect(assignments[3]).to eq(agent2) # Back to second agent
end
end
context 'with balanced assignment' do
before do
# For now, just use round robin since balanced is enterprise only
# The test is verifying the service works, not the specific algorithm
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent2)
end
it 'assigns conversations successfully' do
# Create existing assignments
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2)
end
it 'handles different conversation statuses' do
# Create resolved conversations (should not count)
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :resolved)
# Create open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2) # Selected by mock
end
end
context 'when error occurs' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'returns false and logs error on assignment failure' do
allow(conversation).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(conversation))
expect(Rails.logger).to receive(:error).with(/Failed to assign conversation/)
expect(service.perform_for_conversation(conversation)).to be false
end
end
end
describe '#perform_bulk_assignment' do
before do
create_list(:conversation, 5, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to return agents
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
call_count = 0
allow(selector).to receive(:select_agent) do
call_count += 1
call_count.odd? ? agent1 : agent2
end
end
context 'when policy is enabled' do
it 'assigns multiple conversations' do
assigned_count = service.perform_bulk_assignment(limit: 3)
expect(assigned_count).to eq(3)
expect(inbox.conversations.unassigned.count).to eq(2)
end
it 'respects conversation priority order' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different timestamps
old_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.hour.ago)
new_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.minute.ago)
assignment_policy.update!(conversation_priority: :earliest_created)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).not_to be_nil
expect(new_conversation.reload.assignee).to be_nil
end
it 'handles longest_waiting priority' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different last activity
inactive_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 2.hours.ago)
active_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 5.minutes.ago)
assignment_policy.update!(conversation_priority: :longest_waiting)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(inactive_conversation.reload.assignee).not_to be_nil
expect(active_conversation.reload.assignee).to be_nil
end
it 'returns 0 when no conversations to assign' do
Conversation.find_each { |c| c.update!(assignee_id: agent1.id) }
expect(service.perform_bulk_assignment).to eq(0)
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign any conversations' do
expect(service.perform_bulk_assignment).to eq(0)
expect(inbox.conversations.unassigned.count).to eq(5)
end
end
end
describe 'enterprise capacity features' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do
# Mock enterprise availability
stub_const('Enterprise', Module.new)
stub_const('Enterprise::AssignmentV2::CapacityManager', Class.new)
# Mock the selector to return agent1
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'uses round robin when enterprise features are available' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'handles absence of enterprise features gracefully' do
# Remove enterprise constant
hide_const('Enterprise')
# Service should still work with round robin
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
end
describe 'cache management' do
it 'uses cache for round robin state' do
assignment_policy.update!(assignment_order: :round_robin)
# Mock the selector and round robin service
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
# Create and assign conversations
conversation1 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation1)
conversation2 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation2)
# Just verify assignments worked
expect(conversation1.reload.assignee).to eq(agent1)
expect(conversation2.reload.assignee).to eq(agent1)
end
end
describe 'edge cases' do
it 'handles inbox without policy gracefully' do
inbox_assignment_policy.destroy!
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'handles empty agent list' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'filters out agents without inbox membership' do
non_member_agent = create(:user, account: account, role: :agent, availability: :online)
conversation = create(:conversation, inbox: inbox, assignee: nil)
# Mock selector to return agent1 (who is a member)
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).not_to eq(non_member_agent)
expect(conversation.reload.assignee).to eq(agent1)
end
end
end
@@ -0,0 +1,328 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RateLimiter, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
# Ensure inbox_assignment_policy exists so the inbox has a policy
inbox_assignment_policy
end
let(:account) { create(:account) }
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 3600) }
let(:agent) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: policy) }
let(:rate_limiter) { described_class.new(inbox: inbox, user: agent) }
describe '#initialize' do
it 'sets up rate limiter with inbox and user' do
expect(rate_limiter.instance_variable_get(:@inbox)).to eq(inbox)
expect(rate_limiter.instance_variable_get(:@user)).to eq(agent)
end
end
describe '#within_limits?' do
context 'when agent has no assignments in current window' do
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent is below limit' do
before do
# Create 3 conversations assigned to agent in current window
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent reaches limit' do
before do
# Create 5 conversations assigned to agent (at the limit)
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
context 'when agent exceeds limit' do
before do
# Create 6 conversations assigned to agent (exceeding the limit)
6.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
context 'when assignments are from different inbox' do
let(:other_inbox) { create(:inbox, account: account) }
before do
# Create assignments in different inbox
5.times do
create(:conversation, inbox: other_inbox, assignee: agent, updated_at: Time.current)
end
# Create assignments in current inbox
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'only counts assignments from current inbox' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when assignments are outside time window' do
before do
# Create old assignments outside the window
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: 2.hours.ago)
end
# Create recent assignments within the window
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
it 'only counts assignments within time window' do
expect(rate_limiter.within_limits?).to be true
end
end
end
describe '#status' do
it 'returns correct status for agent with no assignments' do
status = rate_limiter.status
expect(status[:current_count]).to eq(0)
expect(status[:within_limits]).to be true
expect(status[:limit]).to eq(5)
end
it 'returns correct count after assignments' do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
end
it 'includes reset_at time' do
status = rate_limiter.status
expect(status[:reset_at]).to be_a(Time)
expect(status[:reset_at]).to be > Time.current
end
context 'when policy is disabled' do
before do
policy.update!(enabled: false)
end
it 'returns unlimited status' do
status = rate_limiter.status
expect(status[:within_limits]).to be true
expect(status[:current_count]).to eq(0)
expect(status[:limit]).to eq(Float::INFINITY)
expect(status[:reset_at]).to be_nil
end
end
context 'when no policy exists' do
let(:inbox_without_policy) { create(:inbox, account: account) }
let(:rate_limiter_no_policy) { described_class.new(inbox: inbox_without_policy, user: agent) }
it 'returns unlimited status' do
status = rate_limiter_no_policy.status
expect(status[:within_limits]).to be true
expect(status[:current_count]).to eq(0)
expect(status[:limit]).to eq(Float::INFINITY)
expect(status[:reset_at]).to be_nil
end
end
end
describe 'remaining assignments' do
it 'returns full limit when no assignments made' do
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(5)
end
it 'returns correct remaining count' do
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(3)
end
it 'returns 0 when limit reached' do
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(0)
end
it 'returns negative when limit exceeded' do
6.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(-1)
end
end
describe 'assignment capacity checks' do
it 'returns true when agent has remaining capacity' do
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be true
end
it 'returns false when agent has no capacity' do
5.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be false
end
it 'correctly tracks multiple assignments' do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
expect(status[:limit] - status[:current_count]).to eq(2)
end
end
describe 'multiple agents' do
let(:agent2) { create(:user, account: account) }
let(:rate_limiter2) { described_class.new(inbox: inbox, user: agent2) }
before do
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
4.times do
create(:conversation, inbox: inbox, assignee: agent2, updated_at: Time.current)
end
end
it 'tracks status independently for each agent' do
status1 = rate_limiter.status
status2 = rate_limiter2.status
expect(status1[:current_count]).to eq(2)
expect(status1[:within_limits]).to be true
expect(status1[:limit] - status1[:current_count]).to eq(3)
expect(status2[:current_count]).to eq(4)
expect(status2[:within_limits]).to be true
expect(status2[:limit] - status2[:current_count]).to eq(1)
end
end
describe 'window timing' do
it 'calculates reset time correctly' do
# Mock current time to make test predictable
travel_to(Time.zone.parse('2024-01-01 10:30:00')) do
status = rate_limiter.status
reset_time = status[:reset_at]
expect(reset_time).to be_a(Time)
expect(reset_time).to be > Time.current
expect(reset_time - Time.current).to be <= 3600
end
end
end
describe 'window boundaries' do
it 'resets count in new window' do
# Set up assignments in current window
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.status[:current_count]).to eq(2)
# Travel to next window (advance by window size)
travel(3601.seconds) do
expect(rate_limiter.status[:current_count]).to eq(0)
expect(rate_limiter.within_limits?).to be true
end
end
it 'does not count assignments from previous window' do
# Create assignments in previous window
travel_to(2.hours.ago) do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
# Check current window
expect(rate_limiter.status[:current_count]).to eq(0)
expect(rate_limiter.within_limits?).to be true
end
end
describe 'policy configuration' do
context 'with different fair_distribution_limit' do
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 10, fair_distribution_window: 3600) }
it 'uses policy limit' do
8.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be true
2.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
expect(rate_limiter.within_limits?).to be false
end
end
context 'with different fair_distribution_window' do
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 7200) }
it 'uses policy window' do
# Calculate the current window start based on the 2-hour window
current_time = Time.current
window_start_timestamp = (current_time.to_i / 7200) * 7200
# Create assignments 30 minutes after window start (definitely within window)
travel_to(Time.zone.at(window_start_timestamp + 30.minutes)) do
3.times do
create(:conversation, inbox: inbox, assignee: agent, updated_at: Time.current)
end
end
# These should still count toward the limit
expect(rate_limiter.status[:current_count]).to eq(3)
expect(rate_limiter.within_limits?).to be true
end
end
end
end
@@ -0,0 +1,145 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RoundRobinSelector, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
create(:inbox_member, inbox: inbox, user: user1)
create(:inbox_member, inbox: inbox, user: user2)
create(:inbox_member, inbox: inbox, user: user3)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:policy) { create(:assignment_policy, account: account) }
let(:user1) { create(:user, account: account, availability: :online) }
let(:user2) { create(:user, account: account, availability: :online) }
let(:user3) { create(:user, account: account, availability: :offline) }
describe '#select_agent' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
context 'when Redis is available' do
before do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user1.id.to_s)
end
it 'returns an online agent' do
result = selector.select_agent(available_agents)
expect(result).to eq(user1)
end
it 'excludes offline agents' do
result = selector.select_agent(available_agents)
expect(result).not_to eq(user3)
end
it 'handles no available agent gracefully' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(nil)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
context 'when Redis fails' do
before do
allow(round_robin_service).to receive(:available_agent).and_raise(Redis::CannotConnectError)
end
it 'raises the error' do
expect { selector.select_agent(available_agents) }.to raise_error(Redis::CannotConnectError)
end
end
context 'with empty available agents' do
it 'returns nil when no agents are available' do
result = selector.select_agent(InboxMember.none)
expect(result).to be_nil
end
end
context 'with different user IDs' do
it 'correctly finds the inbox member by user_id' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user2.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to eq(user2)
end
end
end
describe '#add_agent_to_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:add_agent_to_queue).with(user1.id)
selector.add_agent_to_queue(user1.id)
end
end
describe '#remove_agent_from_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:remove_agent_from_queue).with(user1.id)
selector.remove_agent_from_queue(user1.id)
end
end
describe '#reset_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:reset_queue)
selector.reset_queue
end
end
describe 'edge cases' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'handles invalid user_id from round robin service' do
allow(round_robin_service).to receive(:available_agent).and_return('invalid_id')
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
it 'handles user_id not in available agents' do
other_user = create(:user, account: account)
allow(round_robin_service).to receive(:available_agent).and_return(other_user.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
end