assignment_v2: add inbox concerns used by Inbox

This commit is contained in:
Tanmay Sharma
2025-08-11 09:09:32 +05:30
parent 9f1a2b53ae
commit 933d310185
4 changed files with 275 additions and 0 deletions
@@ -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
+25
View File
@@ -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