assignment_v2: import assignment policy controllers/models/services/job/policy/specs

This commit is contained in:
Tanmay Sharma
2025-08-11 09:06:30 +05:30
parent 304c938260
commit b2ada112d7
22 changed files with 2214 additions and 90 deletions
+103
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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