assignment_v2: import assignment policy controllers/models/services/job/policy/specs
This commit is contained in:
@@ -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,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,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)
|
||||
|
||||
+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,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,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
|
||||
Reference in New Issue
Block a user