Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36070738f1 | ||
|
|
26e07cdb4e | ||
|
|
3af6e6c493 | ||
|
|
5f03418ac9 | ||
|
|
11370316b2 | ||
|
|
9a09df4563 | ||
|
|
f1832148fd | ||
|
|
24c7171a4e | ||
|
|
b5f3daafc9 | ||
|
|
2c204326e5 | ||
|
|
a33ae58452 | ||
|
|
a96e75b904 | ||
|
|
5bb88192c4 |
@@ -0,0 +1,176 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_enterprise_account
|
||||
before_action :fetch_agent_capacity_policy, only: [:show, :update, :destroy, :set_inbox_limit, :remove_inbox_limit, :assign_user, :remove_user]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@agent_capacity_policies = Enterprise::AgentCapacityPolicy.where(account_id: Current.account.id).includes(:users, :inbox_capacity_limits)
|
||||
render json: { agent_capacity_policies: serialize_agent_capacity_policies(@agent_capacity_policies) }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
|
||||
end
|
||||
|
||||
def create
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy.new(agent_capacity_policy_params)
|
||||
@agent_capacity_policy.account_id = Current.account.id
|
||||
|
||||
if @agent_capacity_policy.save
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }, status: :created
|
||||
else
|
||||
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @agent_capacity_policy.update(agent_capacity_policy_params)
|
||||
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
|
||||
else
|
||||
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @agent_capacity_policy.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Set inbox capacity limit for a policy
|
||||
def set_inbox_limit
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
|
||||
if inbox_limit.update(conversation_limit: params[:conversation_limit])
|
||||
render json: {
|
||||
inbox_capacity_limit: serialize_inbox_capacity_limit(inbox_limit)
|
||||
}
|
||||
else
|
||||
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Remove inbox capacity limit from a policy
|
||||
def remove_inbox_limit
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
|
||||
if inbox_limit
|
||||
if inbox_limit.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'Inbox limit not found' }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
# Assign a user to a capacity policy
|
||||
def assign_user
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
account_user = Current.account.account_users.find_by!(user: user)
|
||||
|
||||
# Update the account_user to assign to this policy
|
||||
if account_user.update(agent_capacity_policy_id: @agent_capacity_policy.id)
|
||||
render json: {
|
||||
message: 'User assigned successfully',
|
||||
user_id: user.id,
|
||||
agent_capacity_policy_id: @agent_capacity_policy.id
|
||||
}
|
||||
else
|
||||
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Remove a user from a capacity policy
|
||||
def remove_user
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
account_user = Current.account.account_users.find_by!(user: user)
|
||||
|
||||
if account_user.agent_capacity_policy_id == @agent_capacity_policy.id
|
||||
if account_user.update(agent_capacity_policy_id: nil)
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'User not assigned to this policy' }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
# Get current capacity status for an agent
|
||||
def agent_capacity
|
||||
user = Current.account.users.find(params[:agent_id])
|
||||
inbox = params[:inbox_id] ? Current.account.inboxes.find(params[:inbox_id]) : nil
|
||||
|
||||
capacity_service = Enterprise::AssignmentV2::CapacityService.new
|
||||
capacity_data = if inbox
|
||||
capacity_service.get_agent_capacity(user, inbox)
|
||||
else
|
||||
capacity_service.get_agent_overall_capacity(user)
|
||||
end
|
||||
|
||||
render json: { agent_capacity: capacity_data }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_enterprise_account
|
||||
return if Current.account.feature_enabled?(:enterprise_agent_capacity)
|
||||
|
||||
render json: {
|
||||
error: 'Agent capacity policies are only available for enterprise accounts'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def fetch_agent_capacity_policy
|
||||
@agent_capacity_policy = Enterprise::AgentCapacityPolicy
|
||||
.where(account_id: Current.account.id)
|
||||
.includes(:users, inbox_capacity_limits: :inbox)
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
def agent_capacity_policy_params
|
||||
params.require(:agent_capacity_policy).permit(:name, :description, exclusion_rules: {})
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Enterprise::AgentCapacityPolicy) if defined?(Enterprise::AgentCapacityPolicy)
|
||||
end
|
||||
|
||||
def serialize_agent_capacity_policy(policy)
|
||||
{
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
description: policy.description,
|
||||
exclusion_rules: policy.exclusion_rules,
|
||||
user_count: policy.users.count,
|
||||
inbox_limit_count: policy.inbox_capacity_limits.count,
|
||||
inbox_limits: policy.inbox_capacity_limits.map { |limit| serialize_inbox_capacity_limit(limit) },
|
||||
users: policy.users.map { |user| { id: user.id, name: user.name, email: user.email, avatar_url: user.avatar_url } },
|
||||
created_at: policy.created_at,
|
||||
updated_at: policy.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_agent_capacity_policies(policies)
|
||||
policies.map { |policy| serialize_agent_capacity_policy(policy) }
|
||||
end
|
||||
|
||||
def serialize_inbox_capacity_limit(limit)
|
||||
{
|
||||
id: limit.id,
|
||||
inbox_id: limit.inbox_id,
|
||||
inbox_name: limit.inbox.name,
|
||||
conversation_limit: limit.conversation_limit,
|
||||
created_at: limit.created_at,
|
||||
updated_at: limit.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,148 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_leave, only: [:show, :update, :destroy, :approve, :reject]
|
||||
before_action -> { check_authorization(Leave) }, only: [:index, :create]
|
||||
before_action :authorize_leave, only: [:show, :update, :destroy]
|
||||
before_action :authorize_approval, only: [:approve, :reject]
|
||||
|
||||
def index
|
||||
@leaves = leave_service.list(filter_params)
|
||||
render json: { leaves: serialize_leaves(@leaves) }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: { leave: serialize_leave(@leave) }
|
||||
end
|
||||
|
||||
def create
|
||||
account_user = find_or_authorize_account_user
|
||||
service = Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
|
||||
result = service.create(leave_params)
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }, status: :created
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
result = leave_service.update(@leave, leave_params)
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @leave.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @leave.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def approve
|
||||
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
|
||||
result = service.approve(params[:comments])
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def reject
|
||||
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
|
||||
result = service.reject(params[:reason])
|
||||
|
||||
if result[:success]
|
||||
render json: { leave: serialize_leave(result[:leave]) }
|
||||
else
|
||||
render json: { errors: result[:errors] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_leave
|
||||
@leave = Current.account.leaves.find(params[:id])
|
||||
end
|
||||
|
||||
def authorize_leave
|
||||
authorize @leave
|
||||
end
|
||||
|
||||
def authorize_approval
|
||||
authorize @leave, :approve?
|
||||
end
|
||||
|
||||
def find_or_authorize_account_user
|
||||
if params[:user_id].present? && Current.account_user.administrator?
|
||||
user = Current.account.users.find(params[:user_id])
|
||||
Current.account.account_users.find_by!(user: user)
|
||||
else
|
||||
Current.account.account_users.find_by!(user: Current.user)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_service
|
||||
@leave_service ||= if @leave
|
||||
Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: @leave.account_user,
|
||||
current_user: Current.user
|
||||
)
|
||||
else
|
||||
# For index action, we don't have a specific leave
|
||||
Leaves::LeaveService.new(
|
||||
account: Current.account,
|
||||
account_user: nil,
|
||||
current_user: Current.user
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def leave_params
|
||||
params.require(:leave).permit(:start_date, :end_date, :leave_type, :reason)
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(:status, :leave_type, :start_date, :end_date, :user_id)
|
||||
end
|
||||
|
||||
def serialize_leave(leave)
|
||||
{
|
||||
id: leave.id,
|
||||
start_date: leave.start_date,
|
||||
end_date: leave.end_date,
|
||||
leave_type: leave.leave_type,
|
||||
status: leave.status,
|
||||
reason: leave.reason,
|
||||
days_count: leave.days_count,
|
||||
approved_by: leave.approved_by&.name,
|
||||
approved_at: leave.approved_at,
|
||||
user: {
|
||||
id: leave.user.id,
|
||||
name: leave.user.name,
|
||||
email: leave.user.email,
|
||||
avatar_url: leave.user.avatar_url
|
||||
},
|
||||
created_at: leave.created_at,
|
||||
updated_at: leave.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_leaves(leaves)
|
||||
leaves.map { |leave| serialize_leave(leave) }
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReassignConversationsJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_user)
|
||||
return unless account_user
|
||||
|
||||
user = account_user.user
|
||||
account = account_user.account
|
||||
|
||||
# Find all open conversations assigned to this user
|
||||
conversations = account.conversations
|
||||
.open
|
||||
.where(assignee: user)
|
||||
|
||||
Rails.logger.info "Reassigning #{conversations.count} conversations for user #{user.name} (#{user.id}) on leave"
|
||||
|
||||
conversations.find_each do |conversation|
|
||||
reassign_conversation(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reassign_conversation(conversation)
|
||||
inbox = conversation.inbox
|
||||
|
||||
# Use Assignment V2 if enabled
|
||||
if inbox.assignment_v2_enabled?
|
||||
assignment_service = AssignmentV2::AssignmentService.new(inbox: inbox)
|
||||
|
||||
# Mark conversation as unassigned first
|
||||
conversation.update!(assignee: nil)
|
||||
|
||||
# Let the assignment service handle it
|
||||
assignment_service.perform_for_conversation(conversation)
|
||||
else
|
||||
# Fallback to auto assignment
|
||||
AutoAssignmentService.new(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: inbox.assignable_agents.map(&:id) - [conversation.assignee_id]
|
||||
).perform
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to reassign conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
|
||||
include Reportable
|
||||
include Featurable
|
||||
include CacheKeys
|
||||
include AssignmentV2FeatureFlag
|
||||
|
||||
SETTINGS_PARAMS_SCHEMA = {
|
||||
'type': 'object',
|
||||
@@ -97,6 +98,11 @@ class Account < ApplicationRecord
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||
has_many :working_hours, dependent: :destroy_async
|
||||
has_many :leaves, dependent: :destroy_async, class_name: 'Leave'
|
||||
|
||||
# Assignment V2 associations
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy' if ChatwootApp.enterprise?
|
||||
|
||||
has_one_attached :contacts_export
|
||||
|
||||
|
||||
+20
-15
@@ -2,24 +2,26 @@
|
||||
#
|
||||
# Table name: account_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
# id :bigint not null, primary key
|
||||
# active_at :datetime
|
||||
# auto_offline :boolean default(TRUE), not null
|
||||
# availability :integer default("online"), not null
|
||||
# role :integer default("agent")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# agent_capacity_policy_id :bigint
|
||||
# custom_role_id :bigint
|
||||
# inviter_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
# index_account_users_on_account_id (account_id)
|
||||
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_account_users_on_custom_role_id (custom_role_id)
|
||||
# index_account_users_on_user_id (user_id)
|
||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||
#
|
||||
|
||||
class AccountUser < ApplicationRecord
|
||||
@@ -28,6 +30,9 @@ class AccountUser < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :user
|
||||
belongs_to :inviter, class_name: 'User', optional: true
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
|
||||
|
||||
has_many :leaves, dependent: :destroy, class_name: 'Leave'
|
||||
|
||||
enum role: { agent: 0, administrator: 1 }
|
||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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(10), 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_enabled (enabled)
|
||||
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Enums
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 }
|
||||
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 }
|
||||
|
||||
# Validate balanced assignment is only available for enterprise
|
||||
validate :validate_balanced_assignment_enterprise_only
|
||||
|
||||
# Server-side validation to prevent bypass
|
||||
before_save :enforce_enterprise_features
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :disabled, -> { where(enabled: false) }
|
||||
|
||||
# Callbacks
|
||||
after_update_commit :clear_assignment_caches
|
||||
after_destroy :clear_assignment_caches
|
||||
|
||||
def can_use_balanced_assignment?
|
||||
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def validate_balanced_assignment_enterprise_only
|
||||
return unless balanced?
|
||||
return if can_use_balanced_assignment?
|
||||
|
||||
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
|
||||
def enforce_enterprise_features
|
||||
# Server-side enforcement to prevent API bypass
|
||||
return unless balanced? && !can_use_balanced_assignment?
|
||||
|
||||
# Force to round_robin if enterprise not available
|
||||
self.assignment_order = 'round_robin'
|
||||
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
|
||||
end
|
||||
|
||||
def clear_assignment_caches
|
||||
# Clear Redis caches when policy is updated
|
||||
Rails.cache.delete("assignment_v2:policy:#{id}")
|
||||
inboxes.find_each do |inbox|
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AssignmentV2FeatureFlag
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def assignment_v2_enabled?
|
||||
# Check account-level feature flag
|
||||
return false unless feature_enabled_for_account?
|
||||
|
||||
# Check for any inbox-level overrides
|
||||
return false if inbox_level_override_disabled?
|
||||
|
||||
# Check system-wide killswitch
|
||||
!GlobalConfig.get('assignment_v2_disabled', false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def feature_enabled_for_account?
|
||||
config = GlobalConfig.get('assignment_v2')
|
||||
return false unless config&.dig('enabled')
|
||||
|
||||
# If no account allowlist, enable for all
|
||||
allowed_accounts = config['accounts']
|
||||
return true if allowed_accounts.blank?
|
||||
|
||||
# Check if account is in allowlist
|
||||
allowed_accounts.include?(id)
|
||||
end
|
||||
|
||||
def inbox_level_override_disabled?
|
||||
return false unless respond_to?(:id)
|
||||
|
||||
# Allow per-inbox disabling during migration
|
||||
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
|
||||
end
|
||||
end
|
||||
@@ -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,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(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
|
||||
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
|
||||
|
||||
available_agents(check_capacity: true).pluck(:user_id)
|
||||
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 capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
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 enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
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
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
|
||||
|
||||
# Filter out agents who are currently on leave
|
||||
on_leave_user_ids = Enterprise::AgentLeave
|
||||
.active
|
||||
.where(account_id: account_id)
|
||||
.pluck(:user_id)
|
||||
|
||||
return inbox_members_scope if on_leave_user_ids.empty?
|
||||
|
||||
inbox_members_scope.where.not(user_id: on_leave_user_ids)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxChannelTypes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxNameSanitization
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :sanitize_name, unless: :new_record?
|
||||
before_save :ensure_name_present
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return handle_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
return sanitized if sanitized.present?
|
||||
|
||||
email? ? (display_name_from_email || '') : ''
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_blank_name
|
||||
email? ? (display_name_from_email || '') : ''
|
||||
end
|
||||
|
||||
def sanitize_name
|
||||
self.name = apply_sanitization_rules(name) if name.present?
|
||||
end
|
||||
|
||||
def ensure_name_present
|
||||
self.name = default_name_for_blank_name if name.blank?
|
||||
self.name = apply_sanitization_rules(name) if name.present?
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
return channel.try(:bot_name) if web_widget?
|
||||
|
||||
readable_name = display_name_from_email if email?
|
||||
readable_name ||= 'Inbox'
|
||||
"#{readable_name} #{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
# Remove forbidden characters and spam-trigger symbols
|
||||
# Keep: letters, numbers, spaces, /'._- and Unicode characters (including emojis)
|
||||
sanitized = name.gsub(/[\\<>@"!#$%&*+=?^`{|}~;:]/, '')
|
||||
# Normalize whitespace
|
||||
sanitized = sanitized.gsub(/\s+/, ' ')
|
||||
# Remove leading and trailing non-word characters (but keep Unicode including emojis)
|
||||
# Use negative lookahead to exclude emoji ranges
|
||||
sanitized = sanitized.gsub(%r{\A[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+|[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+\z}, '')
|
||||
sanitized.strip
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
email_address = channel.try(:imap_email) || channel.try(:email)
|
||||
return nil unless email_address
|
||||
|
||||
local_part = email_address.split('@').first
|
||||
return nil unless local_part
|
||||
|
||||
# Convert underscores and hyphens to spaces and capitalize each word
|
||||
local_part.gsub(/[_-]/, ' ').split.map(&:capitalize).join(' ')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxWebhooks
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
+153
-86
@@ -44,6 +44,11 @@ class Inbox < ApplicationRecord
|
||||
include Avatarable
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include AssignmentV2FeatureFlag
|
||||
include InboxAgentAvailability
|
||||
include InboxChannelTypes
|
||||
include InboxWebhooks
|
||||
include InboxNameSanitization
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -72,6 +77,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
|
||||
@@ -97,109 +106,167 @@ class Inbox < ApplicationRecord
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def assignable_agents
|
||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
# Assignment V2 methods
|
||||
def assignment_v2_enabled?
|
||||
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
def auto_assignment_enabled?
|
||||
if assignment_v2_enabled?
|
||||
assignment_policy.present? && assignment_policy.enabled?
|
||||
else
|
||||
enable_auto_assignment?
|
||||
end
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
members.ids
|
||||
# Returns inbox members who are available for assignment
|
||||
# This method performs all filtering upfront at the database level for optimal performance
|
||||
#
|
||||
# Filters applied:
|
||||
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
|
||||
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
|
||||
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
|
||||
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
|
||||
#
|
||||
# @param options [Hash] Additional filter options
|
||||
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
|
||||
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
|
||||
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
|
||||
#
|
||||
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
|
||||
#
|
||||
# @example Get all available agents
|
||||
# inbox.available_agents
|
||||
#
|
||||
# @example Get available agents excluding specific users
|
||||
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
|
||||
#
|
||||
# @example Get available agents without capacity check (faster but less accurate)
|
||||
# inbox.available_agents(check_capacity: false)
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(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
|
||||
|
||||
private
|
||||
|
||||
def default_name_for_blank_name
|
||||
email? ? display_name_from_email : ''
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
.strip
|
||||
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 capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
channel.email.split('@').first.parameterize.titleize
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
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?
|
||||
|
||||
# Get IDs of inbox members within rate limits
|
||||
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
inbox_member.id if rate_limiter.within_limits?
|
||||
end
|
||||
|
||||
# Return filtered scope maintaining ActiveRecord relation
|
||||
inbox_members_scope.where(id: valid_inbox_member_ids)
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
# Get account users on active leave
|
||||
account_user_ids_on_leave = account.account_users
|
||||
.joins(:leaves)
|
||||
.where(leaves: { status: 'approved' })
|
||||
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
|
||||
.pluck(:id)
|
||||
|
||||
return inbox_members_scope if account_user_ids_on_leave.empty?
|
||||
|
||||
# Exclude inbox members whose account_users are on leave
|
||||
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
|
||||
inbox_members_scope.where.not(user_id: user_ids_on_leave)
|
||||
end
|
||||
|
||||
def dispatch_create_event
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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_inbox_assignment_policy (inbox_id) UNIQUE
|
||||
#
|
||||
|
||||
class InboxAssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# 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
|
||||
|
||||
# Callbacks
|
||||
after_commit :clear_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
|
||||
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
|
||||
|
||||
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
|
||||
|
||||
def clear_inbox_cache
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: leaves
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# approved_at :datetime
|
||||
# end_date :date not null
|
||||
# leave_type :integer default("vacation"), not null
|
||||
# reason :text
|
||||
# start_date :date not null
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# account_user_id :bigint not null
|
||||
# approved_by_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_leaves_on_account_and_status (account_id,status)
|
||||
# index_leaves_on_account_id (account_id)
|
||||
# index_leaves_on_account_user_and_dates (account_user_id,start_date,end_date)
|
||||
# index_leaves_on_account_user_id (account_user_id)
|
||||
# index_leaves_on_approved_by_id (approved_by_id)
|
||||
# index_leaves_on_end_date (end_date)
|
||||
# index_leaves_on_start_date (start_date)
|
||||
# index_leaves_on_status (status)
|
||||
#
|
||||
|
||||
class Leave < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :account_user
|
||||
belongs_to :approved_by, class_name: 'User', optional: true
|
||||
|
||||
has_one :user, through: :account_user
|
||||
|
||||
enum leave_type: {
|
||||
vacation: 0,
|
||||
sick: 1,
|
||||
personal: 2,
|
||||
maternity: 3,
|
||||
paternity: 4,
|
||||
bereavement: 5,
|
||||
unpaid: 6
|
||||
}
|
||||
|
||||
enum status: {
|
||||
pending: 0,
|
||||
approved: 1,
|
||||
rejected: 2,
|
||||
cancelled: 3
|
||||
}
|
||||
|
||||
validates :start_date, presence: true
|
||||
validates :end_date, presence: true
|
||||
validates :leave_type, presence: true
|
||||
validates :status, presence: true
|
||||
validate :end_date_after_start_date
|
||||
validate :no_overlapping_leaves, if: :approved?
|
||||
|
||||
scope :active, -> { approved.where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
|
||||
scope :upcoming, -> { approved.where('start_date > ?', Date.current) }
|
||||
scope :past, -> { where('end_date < ?', Date.current) }
|
||||
scope :by_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
|
||||
|
||||
before_update :set_approved_at, if: -> { status_changed? && approved? }
|
||||
|
||||
def active?
|
||||
approved? && start_date <= Date.current && end_date >= Date.current
|
||||
end
|
||||
|
||||
def days_count
|
||||
return 0 unless start_date && end_date
|
||||
|
||||
(end_date - start_date).to_i + 1
|
||||
end
|
||||
|
||||
def overlaps_with?(other_leave)
|
||||
return false if other_leave == self
|
||||
|
||||
start_date <= other_leave.end_date && end_date >= other_leave.start_date
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def end_date_after_start_date
|
||||
return unless start_date && end_date
|
||||
|
||||
errors.add(:end_date, 'must be after or equal to start date') if end_date < start_date
|
||||
end
|
||||
|
||||
def no_overlapping_leaves
|
||||
overlapping_leaves = account_user.leaves
|
||||
.approved
|
||||
.where.not(id: id)
|
||||
.by_date_range(start_date, end_date)
|
||||
|
||||
return unless overlapping_leaves.exists?
|
||||
|
||||
errors.add(:base, 'Leave dates overlap with an existing approved leave')
|
||||
end
|
||||
|
||||
def set_approved_at
|
||||
self.approved_at = Time.current
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AgentCapacityPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).index?
|
||||
end
|
||||
|
||||
def show?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).show?
|
||||
end
|
||||
|
||||
def create?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).create?
|
||||
end
|
||||
|
||||
def update?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).destroy?
|
||||
end
|
||||
|
||||
def set_inbox_limit?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).set_inbox_limit?
|
||||
end
|
||||
|
||||
def remove_inbox_limit?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).remove_inbox_limit?
|
||||
end
|
||||
|
||||
def assign_user?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).assign_user?
|
||||
end
|
||||
|
||||
def remove_user?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).remove_user?
|
||||
end
|
||||
|
||||
def agent_capacity?
|
||||
return false unless defined?(::Enterprise::AgentCapacityPolicyPolicy)
|
||||
|
||||
::Enterprise::AgentCapacityPolicyPolicy.new(@user_context, @record).agent_capacity?
|
||||
end
|
||||
end
|
||||
@@ -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,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LeavePolicy < ApplicationPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
# Users can view their own leaves or admins can view all
|
||||
record.account_user.user_id == user.id || @account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
# Users can create their own leave requests
|
||||
# When authorizing the class (not instance), allow any authenticated user
|
||||
return true if record.is_a?(Class)
|
||||
|
||||
record.account_user.user_id == user.id
|
||||
end
|
||||
|
||||
def update?
|
||||
# Users can update their own pending/rejected leaves
|
||||
# Admins can update any leave
|
||||
@account_user.administrator? || (record.account_user.user_id == user.id && record.pending?)
|
||||
end
|
||||
|
||||
def destroy?
|
||||
# Users can delete their own pending leaves
|
||||
# Admins can delete any non-approved leave
|
||||
if @account_user.administrator?
|
||||
!record.approved?
|
||||
else
|
||||
record.account_user.user_id == user.id && record.pending?
|
||||
end
|
||||
end
|
||||
|
||||
def approve?
|
||||
# Only admins can approve/reject leaves
|
||||
@account_user.administrator? && record.pending?
|
||||
end
|
||||
|
||||
def reject?
|
||||
approve?
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
if @account_user.administrator?
|
||||
# Admins can see all leaves in the account
|
||||
scope.where(account: account)
|
||||
else
|
||||
# Regular users can only see their own leaves
|
||||
scope.joins(:account_user).where(account: account, account_users: { user_id: user.id })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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 ||= if policy.assignment_order == 'balanced' && enterprise_enabled? && policy.can_use_balanced_assignment?
|
||||
::Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
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)
|
||||
record_assignment_in_rate_limiter(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
|
||||
|
||||
def record_assignment_in_rate_limiter(conversation, agent)
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: inbox, user: agent)
|
||||
rate_limiter.record_assignment(conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Rate limiter for assignment operations
|
||||
# Uses Redis 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
|
||||
|
||||
# Record an assignment for rate limiting purposes
|
||||
# @param conversation [Conversation] The conversation being assigned
|
||||
def record_assignment(_conversation)
|
||||
return unless policy_exists?
|
||||
|
||||
key = rate_limit_key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.multi do |multi|
|
||||
multi.incr(key)
|
||||
multi.expire(key, time_window)
|
||||
end
|
||||
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
|
||||
key = rate_limit_key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.get(key).to_i
|
||||
end
|
||||
|
||||
def rate_limit
|
||||
policy&.fair_distribution_limit || 10
|
||||
end
|
||||
|
||||
def time_window
|
||||
policy&.fair_distribution_window || 3600
|
||||
end
|
||||
|
||||
def rate_limit_key
|
||||
"assignment_v2:rate_limit:#{user.id}:#{current_window}"
|
||||
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
|
||||
@@ -5,7 +5,10 @@ class AutoAssignment::AgentAssignmentService
|
||||
pattr_initialize [:conversation!, :allowed_agent_ids!]
|
||||
|
||||
def find_assignee
|
||||
round_robin_manage_service.available_agent(allowed_agent_ids: allowed_online_agent_ids)
|
||||
assignee_id = round_robin_manage_service.available_agent(allowed_agent_ids: allowed_online_agent_ids)
|
||||
return nil unless assignee_id
|
||||
|
||||
User.find_by(id: assignee_id)
|
||||
end
|
||||
|
||||
def perform
|
||||
|
||||
@@ -18,7 +18,9 @@ class AutoAssignment::InboxRoundRobinService
|
||||
|
||||
def reset_queue
|
||||
clear_queue
|
||||
add_agent_to_queue(inbox.inbox_members.map(&:user_id))
|
||||
inbox.inbox_members.each do |member|
|
||||
add_agent_to_queue(member.user_id)
|
||||
end
|
||||
end
|
||||
|
||||
# end of queue management functions
|
||||
@@ -27,8 +29,7 @@ class AutoAssignment::InboxRoundRobinService
|
||||
# the values of allowed member ids should be in string format
|
||||
def available_agent(allowed_agent_ids: [])
|
||||
reset_queue unless validate_queue?
|
||||
user_id = get_member_from_allowed_agent_ids(allowed_agent_ids)
|
||||
inbox.inbox_members.find_by(user_id: user_id)&.user if user_id.present?
|
||||
get_member_from_allowed_agent_ids(allowed_agent_ids)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -49,7 +50,7 @@ class AutoAssignment::InboxRoundRobinService
|
||||
end
|
||||
|
||||
def validate_queue?
|
||||
return true if inbox.inbox_members.map(&:user_id).sort == queue.map(&:to_i).sort
|
||||
inbox.inbox_members.map(&:user_id).map(&:to_s).sort == queue.sort
|
||||
end
|
||||
|
||||
def queue
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Leaves::LeaveApprovalService
|
||||
pattr_initialize [:leave!, :approver!]
|
||||
|
||||
def approve(comments = nil)
|
||||
return error_response('Leave is not pending') unless leave.pending?
|
||||
return error_response('You are not authorized to approve this leave') unless can_approve?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
leave.update!(
|
||||
status: 'approved',
|
||||
approved_by: approver,
|
||||
approved_at: Time.current
|
||||
)
|
||||
|
||||
create_approval_note(comments) if comments.present?
|
||||
notify_approval
|
||||
reassign_conversations_if_needed
|
||||
end
|
||||
|
||||
{ success: true, leave: leave }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
error_response(e.record.errors.full_messages.join(', '))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "LeaveApprovalService error: #{e.message}"
|
||||
error_response('An error occurred while approving the leave')
|
||||
end
|
||||
|
||||
def reject(reason)
|
||||
return error_response('Leave is not pending') unless leave.pending?
|
||||
return error_response('You are not authorized to reject this leave') unless can_approve?
|
||||
return error_response('Rejection reason is required') if reason.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
leave.update!(
|
||||
status: 'rejected',
|
||||
approved_by: approver,
|
||||
approved_at: Time.current
|
||||
)
|
||||
|
||||
create_rejection_note(reason)
|
||||
notify_rejection
|
||||
end
|
||||
|
||||
{ success: true, leave: leave }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
error_response(e.record.errors.full_messages.join(', '))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "LeaveApprovalService error: #{e.message}"
|
||||
error_response('An error occurred while rejecting the leave')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_approve?
|
||||
account_user = leave.account.account_users.find_by(user: approver)
|
||||
account_user&.administrator?
|
||||
end
|
||||
|
||||
def error_response(message)
|
||||
{ success: false, errors: [message] }
|
||||
end
|
||||
|
||||
def create_approval_note(comments)
|
||||
# This would create a note/activity log if such system exists
|
||||
# For now, we'll just log it
|
||||
Rails.logger.info "Leave #{leave.id} approved by #{approver.name} with comments: #{comments}"
|
||||
end
|
||||
|
||||
def create_rejection_note(reason)
|
||||
# This would create a note/activity log if such system exists
|
||||
# For now, we'll just log it
|
||||
Rails.logger.info "Leave #{leave.id} rejected by #{approver.name} with reason: #{reason}"
|
||||
end
|
||||
|
||||
def notify_approval
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_APPROVED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
approved_by: approver,
|
||||
account: leave.account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def notify_rejection
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_REJECTED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
rejected_by: approver,
|
||||
account: leave.account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def reassign_conversations_if_needed
|
||||
# If the leave starts today or is already active, reassign conversations
|
||||
return unless leave.start_date <= Date.current
|
||||
|
||||
ReassignConversationsJob.perform_later(leave.account_user)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Leaves::LeaveService
|
||||
pattr_initialize [:account!, :account_user!, :current_user!]
|
||||
|
||||
def create(params)
|
||||
leave = account_user.leaves.build(filtered_params(params))
|
||||
leave.account = account
|
||||
|
||||
if leave.save
|
||||
notify_leave_creation(leave)
|
||||
{ success: true, leave: leave }
|
||||
else
|
||||
{ success: false, errors: leave.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def update(leave, params)
|
||||
if leave.update(filtered_params(params))
|
||||
notify_leave_update(leave)
|
||||
{ success: true, leave: leave }
|
||||
else
|
||||
{ success: false, errors: leave.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def cancel(leave)
|
||||
return { success: false, errors: ['Cannot cancel approved leave'] } if leave.approved?
|
||||
|
||||
if leave.update(status: 'cancelled')
|
||||
notify_leave_cancellation(leave)
|
||||
{ success: true, leave: leave }
|
||||
else
|
||||
{ success: false, errors: leave.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
def list(filters = {})
|
||||
scope = policy_scope
|
||||
|
||||
# Apply filters
|
||||
scope = scope.where(status: filters[:status]) if filters[:status].present?
|
||||
scope = scope.where(leave_type: filters[:leave_type]) if filters[:leave_type].present?
|
||||
|
||||
scope = scope.by_date_range(filters[:start_date], filters[:end_date]) if filters[:start_date].present? && filters[:end_date].present?
|
||||
|
||||
scope = scope.joins(:account_user).where(account_users: { user_id: filters[:user_id] }) if filters[:user_id].present? && current_user_admin?
|
||||
|
||||
scope.includes(:account_user, :user, :approved_by).order(start_date: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_params(params)
|
||||
allowed_params = [:start_date, :end_date, :leave_type, :reason]
|
||||
allowed_params << :status if current_user_admin?
|
||||
|
||||
params.slice(*allowed_params)
|
||||
end
|
||||
|
||||
def policy_scope
|
||||
Pundit.policy_scope(user_context, Leave)
|
||||
end
|
||||
|
||||
def user_context
|
||||
{
|
||||
user: current_user,
|
||||
account: account,
|
||||
account_user: account.account_users.find_by(user: current_user)
|
||||
}
|
||||
end
|
||||
|
||||
def current_user_admin?
|
||||
@current_user_admin ||= account.account_users.find_by(user: current_user)&.administrator?
|
||||
end
|
||||
|
||||
def notify_leave_creation(leave)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_CREATED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
account: account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def notify_leave_update(leave)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_UPDATED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
account: account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
|
||||
def notify_leave_cancellation(leave)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
LEAVE_CANCELLED,
|
||||
Time.zone.now,
|
||||
leave: leave,
|
||||
account: account,
|
||||
user: leave.user
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -191,3 +191,7 @@
|
||||
display_name: CRM V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: enterprise_agent_capacity
|
||||
display_name: Enterprise Agent Capacity
|
||||
enabled: true
|
||||
premium: true
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Leave Management Events
|
||||
LEAVE_CREATED = 'leave.created'
|
||||
LEAVE_UPDATED = 'leave.updated'
|
||||
LEAVE_APPROVED = 'leave.approved'
|
||||
LEAVE_REJECTED = 'leave.rejected'
|
||||
LEAVE_CANCELLED = 'leave.cancelled'
|
||||
@@ -217,6 +217,33 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :leaves do
|
||||
member do
|
||||
post :approve
|
||||
post :reject
|
||||
end
|
||||
end
|
||||
|
||||
# Assignment V2 Routes
|
||||
resources :assignment_policies
|
||||
|
||||
resources :inboxes, only: [] do
|
||||
resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies'
|
||||
end
|
||||
|
||||
# Agent Capacity Management (Enterprise)
|
||||
resources :agent_capacity_policies do
|
||||
member do
|
||||
post 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#set_inbox_limit'
|
||||
delete 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#remove_inbox_limit'
|
||||
post 'users', to: 'agent_capacity_policies#assign_user'
|
||||
delete 'users/:user_id', to: 'agent_capacity_policies#remove_user'
|
||||
end
|
||||
end
|
||||
|
||||
# Agent capacity status
|
||||
get 'agents/:agent_id/capacity', to: 'agent_capacity_policies#agent_capacity'
|
||||
|
||||
namespace :twitter do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :assignment_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.integer :assignment_order, null: false, default: 0 # 0: round_robin, 1: balanced
|
||||
t.integer :conversation_priority, null: false, default: 0 # 0: earliest_created, 1: longest_waiting
|
||||
t.integer :fair_distribution_limit, null: false, default: 10
|
||||
t.integer :fair_distribution_window, null: false, default: 3600 # seconds
|
||||
t.boolean :enabled, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :assignment_policies, [:account_id, :name], unique: true, name: 'unique_assignment_policy_name_per_account'
|
||||
add_index :assignment_policies, :enabled, name: 'index_assignment_policies_on_enabled'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateInboxAssignmentPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_assignment_policies do |t|
|
||||
t.references :inbox, null: false, index: true
|
||||
t.references :assignment_policy, null: false, index: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :inbox_assignment_policies, :inbox_id, unique: true, name: 'unique_inbox_assignment_policy'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateEnterpriseAgentCapacityPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :enterprise_agent_capacity_policies do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :name, null: false, limit: 255
|
||||
t.text :description
|
||||
t.jsonb :exclusion_rules, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :enterprise_agent_capacity_policies, [:account_id, :name], unique: true, name: 'unique_capacity_policy_name_per_account'
|
||||
add_index :enterprise_agent_capacity_policies, :exclusion_rules, using: :gin, name: 'index_capacity_policies_on_exclusion_rules'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateEnterpriseInboxCapacityLimits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :enterprise_inbox_capacity_limits do |t|
|
||||
t.references :agent_capacity_policy, null: false, index: { name: 'index_inbox_limits_on_capacity_policy' }
|
||||
t.references :inbox, null: false, index: true
|
||||
t.integer :conversation_limit, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :enterprise_inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true, name: 'unique_policy_inbox_limit'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :leaves do |t|
|
||||
t.references :account, null: false
|
||||
t.references :account_user, null: false
|
||||
t.date :start_date, null: false
|
||||
t.date :end_date, null: false
|
||||
t.integer :leave_type, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
t.text :reason
|
||||
t.references :approved_by
|
||||
t.datetime :approved_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :leaves, :start_date
|
||||
add_index :leaves, :end_date
|
||||
add_index :leaves, :status
|
||||
add_index :leaves, [:account_user_id, :start_date, :end_date], name: 'index_leaves_on_account_user_and_dates'
|
||||
add_index :leaves, [:account_id, :status], name: 'index_leaves_on_account_and_status'
|
||||
end
|
||||
end
|
||||
+74
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_30_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.integer "availability", default: 0, null: false
|
||||
t.boolean "auto_offline", default: true, null: false
|
||||
t.bigint "custom_role_id"
|
||||
t.bigint "agent_capacity_policy_id"
|
||||
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
||||
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
|
||||
t.index ["custom_role_id"], name: "index_account_users_on_custom_role_id"
|
||||
t.index ["user_id"], name: "index_account_users_on_user_id"
|
||||
end
|
||||
@@ -169,6 +171,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["views"], name: "index_articles_on_views"
|
||||
end
|
||||
|
||||
create_table "assignment_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.integer "assignment_order", default: 0, null: false
|
||||
t.integer "conversation_priority", default: 0, null: false
|
||||
t.integer "fair_distribution_limit", default: 10, null: false
|
||||
t.integer "fair_distribution_window", default: 3600, null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "unique_assignment_policy_name_per_account", unique: true
|
||||
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
|
||||
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
t.integer "file_type", default: 0
|
||||
t.string "external_url"
|
||||
@@ -720,6 +738,29 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "enterprise_agent_capacity_policies", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", limit: 255, null: false
|
||||
t.text "description"
|
||||
t.jsonb "exclusion_rules", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "name"], name: "unique_capacity_policy_name_per_account", unique: true
|
||||
t.index ["account_id"], name: "index_enterprise_agent_capacity_policies_on_account_id"
|
||||
t.index ["exclusion_rules"], name: "index_capacity_policies_on_exclusion_rules", using: :gin
|
||||
end
|
||||
|
||||
create_table "enterprise_inbox_capacity_limits", force: :cascade do |t|
|
||||
t.bigint "agent_capacity_policy_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.integer "conversation_limit", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["agent_capacity_policy_id", "inbox_id"], name: "unique_policy_inbox_limit", unique: true
|
||||
t.index ["agent_capacity_policy_id"], name: "index_inbox_limits_on_capacity_policy"
|
||||
t.index ["inbox_id"], name: "index_enterprise_inbox_capacity_limits_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "folders", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "category_id", null: false
|
||||
@@ -728,6 +769,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "inbox_assignment_policies", force: :cascade do |t|
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "assignment_policy_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["assignment_policy_id"], name: "index_inbox_assignment_policies_on_assignment_policy_id"
|
||||
t.index ["inbox_id"], name: "index_inbox_assignment_policies_on_inbox_id"
|
||||
t.index ["inbox_id"], name: "unique_inbox_assignment_policy", unique: true
|
||||
end
|
||||
|
||||
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -800,6 +851,28 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do
|
||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "leaves", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "account_user_id", null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "end_date", null: false
|
||||
t.integer "leave_type", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.text "reason"
|
||||
t.bigint "approved_by_id"
|
||||
t.datetime "approved_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "status"], name: "index_leaves_on_account_and_status"
|
||||
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||
t.index ["account_user_id", "start_date", "end_date"], name: "index_leaves_on_account_user_and_dates"
|
||||
t.index ["account_user_id"], name: "index_leaves_on_account_user_id"
|
||||
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||
t.index ["end_date"], name: "index_leaves_on_end_date"
|
||||
t.index ["start_date"], name: "index_leaves_on_start_date"
|
||||
t.index ["status"], name: "index_leaves_on_status"
|
||||
end
|
||||
|
||||
create_table "macros", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_agent_capacity_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_id :bigint not null
|
||||
# name :string(255) not null
|
||||
# description :text
|
||||
# exclusion_rules :jsonb default: {}
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_enterprise_agent_capacity_policies_on_account_id (account_id)
|
||||
# unique_capacity_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
|
||||
class Enterprise::AgentCapacityPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_agent_capacity_policies'
|
||||
|
||||
# Associations
|
||||
belongs_to :account, class_name: '::Account'
|
||||
has_many :account_users, class_name: '::AccountUser', dependent: :nullify
|
||||
has_many :users, through: :account_users, source: :user, class_name: '::User'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy, class_name: 'Enterprise::InboxCapacityLimit'
|
||||
has_many :inboxes, through: :inbox_capacity_limits
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validate :validate_exclusion_rules_schema
|
||||
|
||||
# Callbacks
|
||||
before_save :validate_inbox_access
|
||||
after_update_commit :invalidate_capacity_caches
|
||||
after_destroy :invalidate_capacity_caches
|
||||
|
||||
# Scopes
|
||||
scope :with_users, -> { joins(:account_users) }
|
||||
scope :for_inbox, ->(inbox) { joins(:inbox_capacity_limits).where(enterprise_inbox_capacity_limits: { inbox: inbox }) }
|
||||
|
||||
def add_user(user)
|
||||
# Find the account_user for this account and user
|
||||
account_user = account.account_users.find_by!(user: user)
|
||||
|
||||
# Update the capacity policy reference
|
||||
account_user.update!(agent_capacity_policy_id: id)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def remove_user(user)
|
||||
account_user = account.account_users.find_by(user: user, agent_capacity_policy_id: id)
|
||||
account_user&.update!(agent_capacity_policy_id: nil)
|
||||
invalidate_user_capacity_cache(user)
|
||||
end
|
||||
|
||||
def set_inbox_limit(inbox, limit)
|
||||
inbox_capacity_limit = inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
|
||||
inbox_capacity_limit.conversation_limit = limit
|
||||
inbox_capacity_limit.save!
|
||||
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
def remove_inbox_limit(inbox)
|
||||
inbox_capacity_limits.where(inbox: inbox).destroy_all
|
||||
invalidate_inbox_capacity_cache(inbox)
|
||||
end
|
||||
|
||||
def get_inbox_limit(inbox)
|
||||
inbox_capacity_limits.find_by(inbox: inbox)&.conversation_limit
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
exclusion_rules: exclusion_rules,
|
||||
users_count: users.count,
|
||||
inboxes_count: inboxes.count
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_exclusion_rules_schema
|
||||
return if exclusion_rules.blank?
|
||||
|
||||
schema = self.class.exclusion_rules_schema
|
||||
schemer = JSONSchemer.schema(schema)
|
||||
validation_errors = schemer.validate(exclusion_rules)
|
||||
|
||||
validation_errors.each do |error|
|
||||
errors.add(:exclusion_rules, error['error'])
|
||||
end
|
||||
end
|
||||
|
||||
def validate_inbox_access
|
||||
# Ensure all specified inboxes belong to the same account
|
||||
invalid_inboxes = inbox_capacity_limits.joins(:inbox)
|
||||
.where.not(inboxes: { account_id: account_id })
|
||||
|
||||
return unless invalid_inboxes.exists?
|
||||
|
||||
errors.add(:inbox_capacity_limits, 'contains inboxes from different accounts')
|
||||
throw :abort
|
||||
end
|
||||
|
||||
def invalidate_capacity_caches
|
||||
users.find_each { |user| invalidate_user_capacity_cache(user) }
|
||||
inboxes.find_each { |inbox| invalidate_inbox_capacity_cache(inbox) }
|
||||
end
|
||||
|
||||
def invalidate_user_capacity_cache(user)
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:#{user.id}:*")
|
||||
end
|
||||
|
||||
def invalidate_inbox_capacity_cache(inbox)
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:*:#{inbox.id}")
|
||||
end
|
||||
|
||||
class << self
|
||||
def exclusion_rules_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
labels: { type: 'array', items: { type: 'string' } },
|
||||
hours_threshold: { type: 'integer', minimum: 1, maximum: 168 }
|
||||
},
|
||||
additionalProperties: false
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_agent_capacity_policy_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
# created_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# unique_user_capacity_policy (user_id) UNIQUE
|
||||
# idx_capacity_policy_users_policy_id (agent_capacity_policy_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (agent_capacity_policy_id => enterprise_agent_capacity_policies.id)
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
|
||||
class Enterprise::AgentCapacityPolicyUser < ApplicationRecord
|
||||
self.table_name = 'enterprise_agent_capacity_policy_users'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :user, class_name: '::User'
|
||||
|
||||
# Validations
|
||||
validates :user_id, uniqueness: true
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :agent_capacity_policy
|
||||
|
||||
# Callbacks
|
||||
after_commit :invalidate_user_cache
|
||||
|
||||
private
|
||||
|
||||
def invalidate_user_cache
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:#{user_id}:*")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: enterprise_inbox_capacity_limits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# conversation_limit :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_limits_on_capacity_policy (agent_capacity_policy_id)
|
||||
# index_enterprise_inbox_capacity_limits_on_inbox_id (inbox_id)
|
||||
# unique_policy_inbox_limit (agent_capacity_policy_id,inbox_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (agent_capacity_policy_id => enterprise_agent_capacity_policies.id)
|
||||
# fk_rails_... (inbox_id => inboxes.id)
|
||||
#
|
||||
|
||||
class Enterprise::InboxCapacityLimit < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
self.table_name = 'enterprise_inbox_capacity_limits'
|
||||
|
||||
# Associations
|
||||
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy'
|
||||
belongs_to :inbox, class_name: '::Inbox'
|
||||
|
||||
# Validations
|
||||
validates :agent_capacity_policy_id, uniqueness: { scope: :inbox_id }
|
||||
validates :conversation_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 1000 }
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :agent_capacity_policy
|
||||
delegate :name, :description, :exclusion_rules, to: :agent_capacity_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_commit :invalidate_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :for_inbox, ->(inbox) { where(inbox: inbox) }
|
||||
scope :for_policy, ->(policy) { where(agent_capacity_policy: policy) }
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
inbox_id: inbox_id,
|
||||
agent_capacity_policy_id: agent_capacity_policy_id,
|
||||
conversation_limit: conversation_limit,
|
||||
policy: agent_capacity_policy.webhook_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def invalidate_inbox_cache
|
||||
Rails.cache.delete_matched("assignment_v2:capacity:*:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AgentCapacityPolicyPolicy < 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
|
||||
|
||||
def set_inbox_limit?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def remove_inbox_limit?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def assign_user?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def remove_user?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def agent_capacity?
|
||||
@account_user.administrator? || @account_user.agent?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AssignmentV2::BalancedSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Since agents are already filtered by capacity, we can compute workload distribution
|
||||
agents_with_workload = compute_agent_workloads(available_agents)
|
||||
return nil if agents_with_workload.empty?
|
||||
|
||||
# Select agent with lowest current workload
|
||||
best_agent_data = agents_with_workload.min_by { |data| data[:current_assignments] }
|
||||
best_agent_data[:agent]
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Balanced selection failed: #{e.message}"
|
||||
# Fallback to simple selection
|
||||
available_agents.first&.user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_agent_workloads(available_agents)
|
||||
available_agents.map do |inbox_member|
|
||||
agent = inbox_member.user
|
||||
|
||||
# Count current assignments
|
||||
current_assignments = agent.assigned_conversations
|
||||
.where(inbox: inbox)
|
||||
.open
|
||||
.count
|
||||
|
||||
{
|
||||
agent: agent,
|
||||
current_assignments: current_assignments
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Enterprise::AssignmentV2::CapacityService
|
||||
def initialize
|
||||
@cache_ttl = 5.minutes
|
||||
end
|
||||
|
||||
# Get agent's current capacity status for specific inbox
|
||||
def get_agent_capacity(agent, inbox)
|
||||
cache_key = capacity_cache_key(agent, inbox)
|
||||
|
||||
cached = Redis::Alfred.hgetall(cache_key)
|
||||
return parse_cached_capacity(cached) if cached.present?
|
||||
|
||||
# Cache miss - compute from database
|
||||
capacity = compute_agent_capacity(agent, inbox)
|
||||
cache_capacity_data(cache_key, capacity)
|
||||
capacity
|
||||
end
|
||||
|
||||
# Get agent's overall capacity across all inboxes
|
||||
def get_agent_overall_capacity(agent)
|
||||
account = agent.accounts.first # Assuming we're working within account context
|
||||
policy = get_agent_capacity_policy(agent, account)
|
||||
|
||||
return unlimited_capacity_summary unless policy
|
||||
|
||||
capacity_data = build_capacity_data_for_policy(agent, policy)
|
||||
build_overall_capacity_response(policy, capacity_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_capacity_data_for_policy(agent, policy)
|
||||
inboxes_data = []
|
||||
total_current = 0
|
||||
total_limit = 0
|
||||
|
||||
policy.inbox_capacity_limits.includes(:inbox).each do |inbox_limit|
|
||||
inbox_data = build_inbox_capacity_data(agent, inbox_limit, policy)
|
||||
|
||||
total_current += inbox_data[:current_assignments]
|
||||
total_limit += inbox_data[:conversation_limit]
|
||||
inboxes_data << inbox_data
|
||||
end
|
||||
|
||||
{
|
||||
inboxes: inboxes_data,
|
||||
total_current: total_current,
|
||||
total_limit: total_limit
|
||||
}
|
||||
end
|
||||
|
||||
def build_inbox_capacity_data(agent, inbox_limit, policy)
|
||||
inbox = inbox_limit.inbox
|
||||
current = count_current_assignments(agent, inbox, policy)
|
||||
limit = inbox_limit.conversation_limit
|
||||
|
||||
{
|
||||
inbox_id: inbox.id,
|
||||
inbox_name: inbox.name,
|
||||
current_assignments: current,
|
||||
conversation_limit: limit,
|
||||
available_capacity: [limit - current, 0].max
|
||||
}
|
||||
end
|
||||
|
||||
def build_overall_capacity_response(policy, capacity_data)
|
||||
{
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
total_current_assignments: capacity_data[:total_current],
|
||||
total_conversation_limit: capacity_data[:total_limit],
|
||||
total_available_capacity: [capacity_data[:total_limit] - capacity_data[:total_current], 0].max,
|
||||
exclusion_rules: policy.exclusion_rules,
|
||||
inboxes: capacity_data[:inboxes]
|
||||
}
|
||||
end
|
||||
|
||||
def compute_agent_capacity(agent, inbox)
|
||||
account = inbox.account
|
||||
policy = get_agent_capacity_policy(agent, account)
|
||||
|
||||
return unlimited_capacity if policy.nil?
|
||||
|
||||
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
return unlimited_capacity if inbox_limit.nil?
|
||||
|
||||
current_assignments = count_current_assignments(agent, inbox, policy)
|
||||
|
||||
{
|
||||
total_capacity: inbox_limit.conversation_limit,
|
||||
current_assignments: current_assignments,
|
||||
available_capacity: [inbox_limit.conversation_limit - current_assignments, 0].max,
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name,
|
||||
exclusion_rules: policy.exclusion_rules
|
||||
}
|
||||
end
|
||||
|
||||
def get_agent_capacity_policy(agent, account)
|
||||
account_user = account.account_users.find_by(user: agent)
|
||||
return nil unless account_user&.agent_capacity_policy_id
|
||||
|
||||
Enterprise::AgentCapacityPolicy.find_by(id: account_user.agent_capacity_policy_id)
|
||||
end
|
||||
|
||||
def count_current_assignments(agent, inbox, policy)
|
||||
scope = agent.assigned_conversations
|
||||
.where(inbox: inbox, status: 'open')
|
||||
|
||||
# Apply exclusion rules from policy
|
||||
scope = apply_exclusion_rules(scope, policy)
|
||||
scope.count
|
||||
end
|
||||
|
||||
def apply_exclusion_rules(scope, policy)
|
||||
rules = policy.exclusion_rules || {}
|
||||
|
||||
# Exclude conversations with specific labels
|
||||
if rules['labels'].present?
|
||||
scope = scope.where.not(id:
|
||||
ConversationLabel.joins(:label)
|
||||
.where(labels: { title: rules['labels'] })
|
||||
.select(:conversation_id))
|
||||
end
|
||||
|
||||
# Exclude conversations older than X hours
|
||||
if rules['hours_threshold'].present?
|
||||
cutoff = rules['hours_threshold'].hours.ago
|
||||
scope = scope.where('conversations.created_at > ?', cutoff)
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def unlimited_capacity
|
||||
{
|
||||
total_capacity: Float::INFINITY,
|
||||
current_assignments: 0,
|
||||
available_capacity: Float::INFINITY,
|
||||
policy_id: nil,
|
||||
policy_name: 'No capacity policy',
|
||||
exclusion_rules: {}
|
||||
}
|
||||
end
|
||||
|
||||
def unlimited_capacity_summary
|
||||
{
|
||||
policy_id: nil,
|
||||
policy_name: 'No capacity policy',
|
||||
total_current_assignments: 0,
|
||||
total_conversation_limit: Float::INFINITY,
|
||||
total_available_capacity: Float::INFINITY,
|
||||
exclusion_rules: {},
|
||||
inboxes: []
|
||||
}
|
||||
end
|
||||
|
||||
def parse_cached_capacity(cached)
|
||||
{
|
||||
total_capacity: cached['total_capacity'].to_i,
|
||||
current_assignments: cached['current_assignments'].to_i,
|
||||
available_capacity: cached['available_capacity'].to_i,
|
||||
policy_id: cached['policy_id'].presence&.to_i,
|
||||
policy_name: cached['policy_name'] || 'No capacity policy',
|
||||
exclusion_rules: JSON.parse(cached['exclusion_rules'] || '{}')
|
||||
}
|
||||
end
|
||||
|
||||
def cache_capacity_data(cache_key, capacity)
|
||||
Redis::Alfred.multi do |multi|
|
||||
multi.hset(cache_key,
|
||||
'total_capacity', capacity[:total_capacity],
|
||||
'current_assignments', capacity[:current_assignments],
|
||||
'available_capacity', capacity[:available_capacity],
|
||||
'policy_id', capacity[:policy_id],
|
||||
'policy_name', capacity[:policy_name],
|
||||
'exclusion_rules', capacity[:exclusion_rules].to_json)
|
||||
multi.expire(cache_key, @cache_ttl)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_cache_key(agent, inbox)
|
||||
"assignment_v2:capacity:#{agent.id}:#{inbox.id}"
|
||||
end
|
||||
end
|
||||
@@ -78,6 +78,11 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.hmget(key, *fields) }
|
||||
end
|
||||
|
||||
# get all fields and values from redis hash
|
||||
def hgetall(key)
|
||||
$alfred.with { |conn| conn.hgetall(key) }
|
||||
end
|
||||
|
||||
# sorted set operations
|
||||
|
||||
# add score and value for a key
|
||||
@@ -100,5 +105,15 @@ module Redis::Alfred
|
||||
def zremrangebyscore(key, range_start, range_end)
|
||||
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
|
||||
end
|
||||
|
||||
# transaction operations
|
||||
|
||||
def multi(&)
|
||||
$alfred.with { |conn| conn.multi(&) }
|
||||
end
|
||||
|
||||
def expire(key, expiry)
|
||||
$alfred.with { |conn| conn.expire(key, expiry) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :assignment_v2 do
|
||||
desc 'Enable Assignment V2 for an account with default policy'
|
||||
task :enable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Create default assignment policy if not exists
|
||||
policy = account.assignment_policies.find_or_create_by!(name: 'Default Policy') do |p|
|
||||
p.description = 'Default round-robin assignment policy'
|
||||
p.assignment_order = 'round_robin'
|
||||
p.conversation_priority = 'earliest_created'
|
||||
p.enabled = true
|
||||
end
|
||||
|
||||
puts "Assignment V2 enabled for account #{account.name} with policy #{policy.name}"
|
||||
end
|
||||
|
||||
desc 'Disable Assignment V2 for an account'
|
||||
task :disable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Disable all assignment policies
|
||||
account.assignment_policies.find_each do |policy|
|
||||
policy.update!(enabled: false)
|
||||
end
|
||||
|
||||
puts "Assignment V2 disabled for account #{account.name}"
|
||||
end
|
||||
|
||||
desc 'Run assignment for all enabled inboxes'
|
||||
task run_all: :environment do
|
||||
count = 0
|
||||
|
||||
Inbox.joins(:assignment_policy)
|
||||
.where(assignment_policies: { enabled: true })
|
||||
.find_each do |inbox|
|
||||
AssignmentV2::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
count += 1
|
||||
end
|
||||
|
||||
puts "Queued assignment jobs for #{count} inboxes"
|
||||
end
|
||||
|
||||
desc 'Show Assignment V2 status'
|
||||
task status: :environment do
|
||||
puts 'Assignment V2 Status'
|
||||
puts '==================='
|
||||
|
||||
total_policies = AssignmentPolicy.count
|
||||
enabled_policies = AssignmentPolicy.enabled.count
|
||||
|
||||
puts "Total Assignment Policies: #{total_policies}"
|
||||
puts "Enabled Policies: #{enabled_policies}"
|
||||
puts
|
||||
|
||||
inboxes_with_v2 = Inbox.joins(:assignment_policy).count
|
||||
total_inboxes = Inbox.count
|
||||
|
||||
puts "Total Inboxes: #{total_inboxes}"
|
||||
puts "Inboxes with Assignment V2: #{inboxes_with_v2}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
@@ -1,6 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Conversation Assignment API', type: :request do
|
||||
before do
|
||||
# Stub GlobalConfig for assignment_v2 feature flag before any objects are created
|
||||
allow(GlobalConfig).to receive(:get) do |key, default = nil|
|
||||
case key
|
||||
when 'assignment_v2'
|
||||
nil
|
||||
when 'assignment_v2_disabled'
|
||||
false
|
||||
when 'assignment_v2_disabled_inboxes'
|
||||
[]
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/conversations/<id>/assignments' do
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Conversations API', type: :request do
|
||||
before do
|
||||
# Stub GlobalConfig for assignment_v2 feature flag before any objects are created
|
||||
allow(GlobalConfig).to receive(:get) do |key, default = nil|
|
||||
case key
|
||||
when 'assignment_v2'
|
||||
nil
|
||||
when 'assignment_v2_disabled'
|
||||
false
|
||||
when 'assignment_v2_disabled_inboxes'
|
||||
[]
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations' do
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Leaves API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:agent_account_user) { account.account_users.find_by(user: agent) }
|
||||
let(:another_agent) { create(:user, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/leaves' do
|
||||
context 'when authenticated as an agent' do
|
||||
it 'returns only their own leaves' do
|
||||
leave1 = create(:leave, account_user: agent_account_user, account: account)
|
||||
create(:leave, account_user: account.account_users.find_by(user: another_agent), account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/leaves",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leaves'].size).to eq(1)
|
||||
expect(json_response['leaves'].first['id']).to eq(leave1.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'returns all leaves in the account' do
|
||||
create(:leave, account_user: agent_account_user, account: account)
|
||||
create(:leave, account_user: account.account_users.find_by(user: another_agent), account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/leaves",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leaves'].size).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves' do
|
||||
context 'when authenticated as an agent' do
|
||||
it 'creates a leave request for themselves' do
|
||||
leave_params = {
|
||||
leave: {
|
||||
start_date: Date.current + 1.day,
|
||||
end_date: Date.current + 7.days,
|
||||
leave_type: 'vacation',
|
||||
reason: 'Annual vacation'
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/leaves",
|
||||
params: leave_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['status']).to eq('pending')
|
||||
expect(json_response['leave']['leave_type']).to eq('vacation')
|
||||
end
|
||||
|
||||
it 'validates date order' do
|
||||
leave_params = {
|
||||
leave: {
|
||||
start_date: Date.current + 7.days,
|
||||
end_date: Date.current + 1.day,
|
||||
leave_type: 'vacation',
|
||||
reason: 'Invalid dates'
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/leaves",
|
||||
params: leave_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['errors']).to include('End date must be after or equal to start date')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/:account_id/leaves/:id' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
context 'when authenticated as the leave owner' do
|
||||
it 'updates pending leave' do
|
||||
update_params = {
|
||||
leave: {
|
||||
end_date: Date.current + 10.days,
|
||||
reason: 'Extended vacation'
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['reason']).to eq('Extended vacation')
|
||||
end
|
||||
|
||||
it 'cannot update approved leave' do
|
||||
leave.update!(status: 'approved')
|
||||
|
||||
update_params = {
|
||||
leave: {
|
||||
end_date: Date.current + 10.days
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves/:id/approve' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'approves the leave' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/approve",
|
||||
params: { comments: 'Approved for vacation' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['status']).to eq('approved')
|
||||
expect(json_response['leave']['approved_by']).to eq(admin.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as a regular agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/approve",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/leaves/:id/reject' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
context 'when authenticated as an admin' do
|
||||
it 'rejects the leave with reason' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/reject",
|
||||
params: { reason: 'Not enough coverage' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['leave']['status']).to eq('rejected')
|
||||
end
|
||||
|
||||
it 'requires rejection reason' do
|
||||
post "/api/v1/accounts/#{account.id}/leaves/#{leave.id}/reject",
|
||||
params: { reason: '' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['errors']).to include('Rejection reason is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/leaves/:id' do
|
||||
context 'when deleting own pending leave' do
|
||||
let(:leave) { create(:leave, account_user: agent_account_user, account: account) }
|
||||
|
||||
it 'deletes the leave' do
|
||||
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(Leave.find_by(id: leave.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when trying to delete approved leave' do
|
||||
let(:leave) { create(:leave, :approved, account_user: agent_account_user, account: account) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/leaves/#{leave.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||
expect(Leave.find_by(id: leave.id)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :enterprise_agent_capacity_policy, class: 'Enterprise::AgentCapacityPolicy' do
|
||||
account
|
||||
sequence(:name) { |n| "Capacity Policy #{n}" }
|
||||
description { 'Test capacity policy' }
|
||||
exclusion_rules { {} }
|
||||
|
||||
trait :with_label_exclusion do
|
||||
exclusion_rules { { 'labels' => %w[vip urgent] } }
|
||||
end
|
||||
|
||||
trait :with_time_exclusion do
|
||||
exclusion_rules { { 'hours_threshold' => 24 } }
|
||||
end
|
||||
|
||||
trait :with_combined_exclusions do
|
||||
exclusion_rules do
|
||||
{
|
||||
'labels' => %w[vip urgent],
|
||||
'hours_threshold' => 48
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :enterprise_inbox_capacity_limit, class: 'Enterprise::InboxCapacityLimit' do
|
||||
association :agent_capacity_policy, factory: :enterprise_agent_capacity_policy
|
||||
inbox
|
||||
conversation_limit { 10 }
|
||||
|
||||
trait :low_limit do
|
||||
conversation_limit { 3 }
|
||||
end
|
||||
|
||||
trait :high_limit do
|
||||
conversation_limit { 50 }
|
||||
end
|
||||
|
||||
trait :max_limit do
|
||||
conversation_limit { 1000 }
|
||||
end
|
||||
|
||||
# Ensure inbox and policy belong to same account
|
||||
after(:build) do |limit|
|
||||
limit.agent_capacity_policy.account = limit.inbox.account if limit.inbox && limit.agent_capacity_policy
|
||||
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
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :leave do
|
||||
account
|
||||
account_user
|
||||
start_date { Date.current + 1.day }
|
||||
end_date { Date.current + 7.days }
|
||||
leave_type { 'vacation' }
|
||||
status { 'pending' }
|
||||
reason { 'Annual vacation' }
|
||||
|
||||
trait :approved do
|
||||
status { 'approved' }
|
||||
approved_by { create(:user) }
|
||||
approved_at { Time.current }
|
||||
end
|
||||
|
||||
trait :rejected do
|
||||
status { 'rejected' }
|
||||
approved_by { create(:user) }
|
||||
approved_at { Time.current }
|
||||
end
|
||||
|
||||
trait :cancelled do
|
||||
status { 'cancelled' }
|
||||
end
|
||||
|
||||
trait :active do
|
||||
approved
|
||||
start_date { Date.current }
|
||||
end_date { Date.current + 7.days }
|
||||
end
|
||||
|
||||
trait :past do
|
||||
approved
|
||||
start_date { Date.current - 14.days }
|
||||
end_date { Date.current - 7.days }
|
||||
end
|
||||
|
||||
trait :future do
|
||||
approved
|
||||
start_date { Date.current + 7.days }
|
||||
end_date { Date.current + 14.days }
|
||||
end
|
||||
|
||||
trait :sick_leave do
|
||||
leave_type { 'sick' }
|
||||
reason { 'Medical reasons' }
|
||||
end
|
||||
|
||||
trait :personal_leave do
|
||||
leave_type { 'personal' }
|
||||
reason { 'Personal matters' }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
# 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
|
||||
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
|
||||
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
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
|
||||
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
|
||||
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
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)
|
||||
|
||||
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
|
||||
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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) }
|
||||
|
||||
context 'with balanced assignment validation' do
|
||||
let(:enterprise_account) { create(:account) }
|
||||
|
||||
before do
|
||||
allow(enterprise_account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'allows balanced assignment for enterprise accounts' do
|
||||
policy = build(:assignment_policy, account: enterprise_account, assignment_order: :balanced)
|
||||
expect(policy).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects balanced assignment for non-enterprise accounts' do
|
||||
policy = build(:assignment_policy, account: account, assignment_order: :balanced)
|
||||
expect(policy).not_to be_valid
|
||||
expect(policy.errors[:assignment_order]).to include('Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0, balanced: 1) }
|
||||
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
|
||||
|
||||
it 'filters enabled policies' do
|
||||
expect(described_class.enabled).to include(enabled_policy)
|
||||
expect(described_class.enabled).not_to include(disabled_policy)
|
||||
end
|
||||
|
||||
it 'filters disabled policies' do
|
||||
expect(described_class.disabled).to include(disabled_policy)
|
||||
expect(described_class.disabled).not_to include(enabled_policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_use_balanced_assignment?' do
|
||||
context 'when account has enterprise agent capacity feature' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(assignment_policy.can_use_balanced_assignment?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not have enterprise features' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(assignment_policy.can_use_balanced_assignment?).to be false
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
describe 'cache invalidation' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
before { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
it 'clears assignment caches on update' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
assignment_policy.update!(name: 'Updated Policy')
|
||||
end
|
||||
|
||||
it 'clears assignment caches on destroy' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
assignment_policy.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,22 @@ require 'rails_helper'
|
||||
|
||||
shared_examples_for 'assignment_handler' do
|
||||
describe '#update_team' do
|
||||
before do
|
||||
# Stub GlobalConfig for assignment_v2 feature flag before any objects are created
|
||||
allow(GlobalConfig).to receive(:get) do |key, default = nil|
|
||||
case key
|
||||
when 'assignment_v2'
|
||||
nil
|
||||
when 'assignment_v2_disabled'
|
||||
false
|
||||
when 'assignment_v2_disabled_inboxes'
|
||||
[]
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:conversation) { create(:conversation, assignee: create(:user)) }
|
||||
let(:agent) do
|
||||
create(:user, email: 'agent@example.com', account: conversation.account, role: :agent, auto_offline: false)
|
||||
|
||||
@@ -4,6 +4,24 @@ require 'rails_helper'
|
||||
|
||||
shared_examples_for 'auto_assignment_handler' do
|
||||
describe '#auto assignment' do
|
||||
before do
|
||||
# Stub GlobalConfig for assignment_v2 feature flag before any objects are created
|
||||
allow(GlobalConfig).to receive(:get) do |key, default = nil|
|
||||
case key
|
||||
when 'assignment_v2'
|
||||
nil
|
||||
when 'assignment_v2_disabled'
|
||||
false
|
||||
when 'assignment_v2_disabled_inboxes'
|
||||
[]
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
allow(Redis::Alfred).to receive(:rpoplpush).and_return(agent.id)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account, auto_offline: false) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
@@ -17,11 +35,6 @@ shared_examples_for 'auto_assignment_handler' do
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
allow(Redis::Alfred).to receive(:rpoplpush).and_return(agent.id)
|
||||
end
|
||||
|
||||
it 'runs round robin on after_save callbacks' do
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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 'scopes' do
|
||||
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
|
||||
let(:inbox2) { create(:inbox, account: account) }
|
||||
let!(:enabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: enabled_policy) }
|
||||
let!(:disabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: disabled_policy) }
|
||||
|
||||
describe '.enabled' do
|
||||
it 'returns only inbox policies with enabled assignment policies' do
|
||||
expect(described_class.enabled).to include(enabled_inbox_policy)
|
||||
expect(described_class.enabled).not_to include(disabled_inbox_policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.disabled' do
|
||||
it 'returns only inbox policies with disabled assignment policies' do
|
||||
expect(described_class.disabled).to include(disabled_inbox_policy)
|
||||
expect(described_class.disabled).not_to include(enabled_inbox_policy)
|
||||
end
|
||||
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 'cache management' do
|
||||
it 'clears inbox cache on create' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'clears inbox cache on update' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
|
||||
|
||||
inbox_assignment_policy.update!(updated_at: Time.current)
|
||||
end
|
||||
|
||||
it 'clears inbox cache on destroy' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
|
||||
|
||||
inbox_assignment_policy.destroy!
|
||||
end
|
||||
|
||||
it 'updates account cache' do
|
||||
# AccountCacheRevalidator concern should trigger cache update
|
||||
expect(inbox_assignment_policy).to receive(:update_account_cache)
|
||||
|
||||
inbox_assignment_policy.send(:clear_inbox_cache)
|
||||
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,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Inbox Leave Integration', skip: 'Enterprise feature', type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
let(:user3) { create(:user) }
|
||||
let(:account_user1) { create(:account_user, account: account, user: user1) }
|
||||
let(:account_user2) { create(:account_user, account: account, user: user2) }
|
||||
let(:account_user3) { create(:account_user, account: account, user: user3) }
|
||||
|
||||
before do
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: user1)
|
||||
create(:inbox_member, inbox: inbox, user: user2)
|
||||
create(:inbox_member, inbox: inbox, user: user3)
|
||||
|
||||
# Set all users as online
|
||||
OnlineStatusTracker.set_status(account.id, user1.id, 'online')
|
||||
OnlineStatusTracker.set_status(account.id, user2.id, 'online')
|
||||
OnlineStatusTracker.set_status(account.id, user3.id, 'online')
|
||||
end
|
||||
|
||||
describe '#available_agents' do
|
||||
context 'when no one is on leave' do
|
||||
it 'returns all online agents' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an agent is on approved leave' do
|
||||
before do
|
||||
create(:leave, :active, account_user: account_user1)
|
||||
end
|
||||
|
||||
it 'excludes the agent on leave' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).to contain_exactly(user2.id, user3.id)
|
||||
expect(available.map(&:user_id)).not_to include(user1.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple agents are on leave' do
|
||||
before do
|
||||
create(:leave, :active, account_user: account_user1)
|
||||
create(:leave, :active, account_user: account_user2)
|
||||
end
|
||||
|
||||
it 'excludes all agents on leave' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).to contain_exactly(user3.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an agent has pending leave' do
|
||||
before do
|
||||
create(:leave, account_user: account_user1, status: 'pending')
|
||||
end
|
||||
|
||||
it 'does not exclude agents with pending leave' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an agent has future approved leave' do
|
||||
before do
|
||||
create(:leave, :future, account_user: account_user1)
|
||||
end
|
||||
|
||||
it 'does not exclude agents with future leave' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an agent has past leave' do
|
||||
before do
|
||||
create(:leave, :past, account_user: account_user1)
|
||||
end
|
||||
|
||||
it 'does not exclude agents with past leave' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).to contain_exactly(user1.id, user2.id, user3.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with exclude_on_leave option' do
|
||||
before do
|
||||
create(:leave, :active, account_user: account_user1)
|
||||
end
|
||||
|
||||
it 'excludes agents on leave by default' do
|
||||
available = inbox.available_agents
|
||||
expect(available.map(&:user_id)).not_to include(user1.id)
|
||||
end
|
||||
|
||||
it 'includes agents on leave when exclude_on_leave is false' do
|
||||
available = inbox.available_agents(exclude_on_leave: false)
|
||||
expect(available.map(&:user_id)).to include(user1.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Leave, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:account_user) }
|
||||
it { is_expected.to belong_to(:approved_by).class_name('User').optional }
|
||||
it { is_expected.to have_one(:user).through(:account_user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:start_date) }
|
||||
it { is_expected.to validate_presence_of(:end_date) }
|
||||
it { is_expected.to validate_presence_of(:leave_type) }
|
||||
it { is_expected.to validate_presence_of(:status) }
|
||||
|
||||
describe 'end_date_after_start_date' do
|
||||
let(:leave) { build(:leave, start_date: Date.current, end_date: Date.current - 1.day) }
|
||||
|
||||
it 'validates end date is after start date' do
|
||||
expect(leave).not_to be_valid
|
||||
expect(leave.errors[:end_date]).to include('must be after or equal to start date')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'no_overlapping_leaves' do
|
||||
let(:account_user) { create(:account_user) }
|
||||
let(:new_leave) do
|
||||
build(:leave, account_user: account_user, start_date: Date.current + 3.days, end_date: Date.current + 10.days, status: 'approved')
|
||||
end
|
||||
|
||||
before { create(:leave, :approved, account_user: account_user, start_date: Date.current, end_date: Date.current + 7.days) }
|
||||
|
||||
it 'prevents overlapping approved leaves' do
|
||||
expect(new_leave).not_to be_valid
|
||||
expect(new_leave.errors[:base]).to include('Leave dates overlap with an existing approved leave')
|
||||
end
|
||||
|
||||
it 'allows overlapping pending leaves' do
|
||||
new_leave.status = 'pending'
|
||||
expect(new_leave).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it {
|
||||
expect(subject).to define_enum_for(:leave_type).with_values(vacation: 0, sick: 1, personal: 2, maternity: 3, paternity: 4, bereavement: 5,
|
||||
unpaid: 6)
|
||||
}
|
||||
|
||||
it { is_expected.to define_enum_for(:status).with_values(pending: 0, approved: 1, rejected: 2, cancelled: 3) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:active_leave) { create(:leave, :active) }
|
||||
let!(:upcoming_leave) { create(:leave, :future) }
|
||||
let!(:past_leave) { create(:leave, :past) }
|
||||
let!(:pending_leave) { create(:leave) }
|
||||
|
||||
describe '.active' do
|
||||
it 'returns leaves that are currently active' do
|
||||
expect(described_class.active).to include(active_leave)
|
||||
expect(described_class.active).not_to include(upcoming_leave, past_leave, pending_leave)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.upcoming' do
|
||||
it 'returns approved leaves starting in the future' do
|
||||
expect(described_class.upcoming).to include(upcoming_leave)
|
||||
expect(described_class.upcoming).not_to include(active_leave, past_leave, pending_leave)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.past' do
|
||||
it 'returns leaves that have ended' do
|
||||
expect(described_class.past).to include(past_leave)
|
||||
expect(described_class.past).not_to include(active_leave, upcoming_leave, pending_leave)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for approved leaves within current date' do
|
||||
leave = build(:leave, :active)
|
||||
expect(leave.active?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for pending leaves' do
|
||||
leave = build(:leave)
|
||||
expect(leave.active?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for future leaves' do
|
||||
leave = build(:leave, :future)
|
||||
expect(leave.active?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#days_count' do
|
||||
it 'calculates the number of days' do
|
||||
leave = build(:leave, start_date: Date.current, end_date: Date.current + 6.days)
|
||||
expect(leave.days_count).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#overlaps_with?' do
|
||||
let(:leave1) { build(:leave, start_date: Date.current, end_date: Date.current + 7.days) }
|
||||
let(:leave2) { build(:leave, start_date: Date.current + 3.days, end_date: Date.current + 10.days) }
|
||||
let(:leave3) { build(:leave, start_date: Date.current + 8.days, end_date: Date.current + 15.days) }
|
||||
|
||||
it 'returns true for overlapping leaves' do
|
||||
expect(leave1.overlaps_with?(leave2)).to be true
|
||||
expect(leave2.overlaps_with?(leave1)).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-overlapping leaves' do
|
||||
expect(leave1.overlaps_with?(leave3)).to be false
|
||||
expect(leave3.overlaps_with?(leave1)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
describe 'set_approved_at' do
|
||||
let(:leave) { create(:leave) }
|
||||
|
||||
it 'sets approved_at when status changes to approved' do
|
||||
expect(leave.approved_at).to be_nil
|
||||
leave.update!(status: 'approved')
|
||||
expect(leave.approved_at).to be_present
|
||||
end
|
||||
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,293 @@
|
||||
# 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({})
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.flushdb if Rails.env.test?
|
||||
|
||||
# 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
|
||||
# Simulate 3 assignments in current window
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.within_limits?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent reaches limit' do
|
||||
before do
|
||||
# Simulate reaching the limit (5 assignments)
|
||||
5.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(rate_limiter.within_limits?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent exceeds limit' do
|
||||
before do
|
||||
# Simulate exceeding the limit
|
||||
6.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(rate_limiter.within_limits?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#record_assignment' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox) }
|
||||
|
||||
it 'increments assignment count for agent' do
|
||||
initial_status = rate_limiter.status
|
||||
rate_limiter.record_assignment(conversation)
|
||||
new_status = rate_limiter.status
|
||||
|
||||
expect(new_status[:current_count]).to eq(initial_status[:current_count] + 1)
|
||||
end
|
||||
|
||||
it 'sets expiration on the key' do
|
||||
rate_limiter.record_assignment(conversation)
|
||||
|
||||
current_window = (Time.current.to_i / 3600) * 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
ttl = redis.ttl(key)
|
||||
expect(ttl).to be > 0
|
||||
expect(ttl).to be <= 3600
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
redis_double = instance_double(Redis)
|
||||
allow(Redis).to receive(:new).and_return(redis_double)
|
||||
allow(redis_double).to receive(:multi).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises error' do
|
||||
expect { rate_limiter.record_assignment(conversation) }.to raise_error(Redis::ConnectionError)
|
||||
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 { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:current_count]).to eq(3)
|
||||
expect(status[:within_limits]).to be true
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
redis_double = instance_double(Redis)
|
||||
allow(Redis).to receive(:new).and_return(redis_double)
|
||||
allow(redis_double).to receive(:get).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises error' do
|
||||
expect { rate_limiter.status }.to raise_error(Redis::ConnectionError)
|
||||
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 { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:limit] - status[:current_count]).to eq(3)
|
||||
end
|
||||
|
||||
it 'returns 0 when limit reached' do
|
||||
5.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:limit] - status[:current_count]).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns negative when limit exceeded' do
|
||||
6.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
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 { rate_limiter.record_assignment(create(:conversation)) }
|
||||
expect(rate_limiter.within_limits?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when agent has no capacity' do
|
||||
5.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
expect(rate_limiter.within_limits?).to be false
|
||||
end
|
||||
|
||||
it 'correctly tracks multiple assignments' do
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
|
||||
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 { rate_limiter.record_assignment(create(:conversation)) }
|
||||
4.times { rate_limiter2.record_assignment(create(:conversation)) }
|
||||
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 'reset functionality' do
|
||||
before do
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'can be reset by clearing Redis key' do
|
||||
status_before = rate_limiter.status
|
||||
expect(status_before[:current_count]).to eq(3)
|
||||
|
||||
# Manually clear the key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
current_window = (Time.current.to_i / 3600) * 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
redis.del(key)
|
||||
|
||||
status_after = rate_limiter.status
|
||||
expect(status_after[:current_count]).to eq(0)
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
redis_double = instance_double(Redis)
|
||||
allow(Redis).to receive(:new).and_return(redis_double)
|
||||
allow(redis_double).to receive(:del).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises error' do
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
current_window = (Time.current.to_i / 3600) * 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
expect { redis.del(key) }.to raise_error(Redis::ConnectionError)
|
||||
end
|
||||
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 assignment in current window
|
||||
2.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
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
|
||||
end
|
||||
|
||||
describe 'concurrent access' do
|
||||
it 'handles concurrent increments correctly' do
|
||||
threads = []
|
||||
results = []
|
||||
mutex = Mutex.new
|
||||
|
||||
# Simulate concurrent assignment requests
|
||||
5.times do
|
||||
threads << Thread.new do
|
||||
within_limits = rate_limiter.within_limits?
|
||||
mutex.synchronize { results << within_limits }
|
||||
rate_limiter.record_assignment(create(:conversation)) if within_limits
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# Final count should not exceed the limit
|
||||
final_count = rate_limiter.status[:current_count]
|
||||
expect(final_count).to be <= 5
|
||||
expect(results.count(true)).to eq(final_count)
|
||||
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
|
||||
@@ -1,6 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutoAssignment::AgentAssignmentService do
|
||||
before do
|
||||
# Stub GlobalConfig for assignment_v2 feature flag before any objects are created
|
||||
allow(GlobalConfig).to receive(:get) do |key, default = nil|
|
||||
case key
|
||||
when 'assignment_v2'
|
||||
nil
|
||||
when 'assignment_v2_disabled'
|
||||
false
|
||||
when 'assignment_v2_disabled_inboxes'
|
||||
[]
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
inbox_members.each { |inbox_member| create(:account_user, account: account, user: inbox_member.user) }
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return(online_users)
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let!(:inbox_members) { create_list(:inbox_member, 5, inbox: inbox) }
|
||||
@@ -15,11 +33,6 @@ RSpec.describe AutoAssignment::AgentAssignmentService do
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
inbox_members.each { |inbox_member| create(:account_user, account: account, user: inbox_member.user) }
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return(online_users)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'will assign an online agent to the conversation' do
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
|
||||
@@ -45,13 +45,13 @@ describe AutoAssignment::InboxRoundRobinService do
|
||||
inbox_members[3].user_id,
|
||||
inbox_members[2].user_id
|
||||
].map(&:to_s)
|
||||
)).to eq inbox_members[2].user
|
||||
)).to eq inbox_members[2].user_id.to_s
|
||||
expect(described_class.new(inbox: inbox).available_agent(
|
||||
allowed_agent_ids: [
|
||||
inbox_members[3].user_id,
|
||||
inbox_members[2].user_id
|
||||
].map(&:to_s)
|
||||
)).to eq inbox_members[3].user
|
||||
)).to eq inbox_members[3].user_id.to_s
|
||||
expect(inbox_round_robin_service.send(:queue)).to eq(expected_queue)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,7 +32,20 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
|
||||
before do
|
||||
account.enable_features('crm_integration')
|
||||
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
|
||||
allow(GlobalConfig).to receive(:get) do |key, default = nil|
|
||||
case key
|
||||
when 'BRAND_NAME'
|
||||
{ 'BRAND_NAME' => 'TestBrand' }
|
||||
when 'assignment_v2'
|
||||
nil
|
||||
when 'assignment_v2_disabled'
|
||||
false
|
||||
when 'assignment_v2_disabled_inboxes'
|
||||
[]
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.map_conversation_activity' do
|
||||
@@ -44,7 +57,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
expect(result).to include('Created: 2024-01-01 10:00:00')
|
||||
expect(result).to include("Conversation ID: #{conversation.display_id}")
|
||||
expect(result).to include('View in TestBrand: http://')
|
||||
expect(result).to match(%r{View in TestBrand: https?://})
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user