Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ddb47eac | ||
|
|
2ebde48d15 |
@@ -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
|
||||||
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
|
|||||||
include Reportable
|
include Reportable
|
||||||
include Featurable
|
include Featurable
|
||||||
include CacheKeys
|
include CacheKeys
|
||||||
|
include AssignmentV2FeatureFlag
|
||||||
|
|
||||||
SETTINGS_PARAMS_SCHEMA = {
|
SETTINGS_PARAMS_SCHEMA = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -97,6 +98,11 @@ class Account < ApplicationRecord
|
|||||||
has_many :webhooks, dependent: :destroy_async
|
has_many :webhooks, dependent: :destroy_async
|
||||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||||
has_many :working_hours, dependent: :destroy_async
|
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
|
has_one_attached :contacts_export
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# account_id :bigint
|
# account_id :bigint
|
||||||
|
# agent_capacity_policy_id :bigint
|
||||||
# custom_role_id :bigint
|
# custom_role_id :bigint
|
||||||
# inviter_id :bigint
|
# inviter_id :bigint
|
||||||
# user_id :bigint
|
# user_id :bigint
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_account_users_on_account_id (account_id)
|
# 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_custom_role_id (custom_role_id)
|
||||||
# index_account_users_on_user_id (user_id)
|
# index_account_users_on_user_id (user_id)
|
||||||
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
|
||||||
@@ -28,6 +30,9 @@ class AccountUser < ApplicationRecord
|
|||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :inviter, class_name: 'User', optional: true
|
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 role: { agent: 0, administrator: 1 }
|
||||||
enum availability: { online: 0, offline: 1, busy: 2 }
|
enum availability: { online: 0, offline: 1, busy: 2 }
|
||||||
|
|||||||
+153
-86
@@ -44,6 +44,11 @@ class Inbox < ApplicationRecord
|
|||||||
include Avatarable
|
include Avatarable
|
||||||
include OutOfOffisable
|
include OutOfOffisable
|
||||||
include AccountCacheRevalidator
|
include AccountCacheRevalidator
|
||||||
|
include AssignmentV2FeatureFlag
|
||||||
|
include InboxAgentAvailability
|
||||||
|
include InboxChannelTypes
|
||||||
|
include InboxWebhooks
|
||||||
|
include InboxNameSanitization
|
||||||
|
|
||||||
# Not allowing characters:
|
# Not allowing characters:
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
@@ -72,6 +77,10 @@ class Inbox < ApplicationRecord
|
|||||||
has_many :webhooks, dependent: :destroy_async
|
has_many :webhooks, dependent: :destroy_async
|
||||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
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 }
|
enum sender_name_type: { friendly: 0, professional: 1 }
|
||||||
|
|
||||||
after_destroy :delete_round_robin_agents
|
after_destroy :delete_round_robin_agents
|
||||||
@@ -97,109 +106,167 @@ class Inbox < ApplicationRecord
|
|||||||
update_account_cache
|
update_account_cache
|
||||||
end
|
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
|
def assignable_agents
|
||||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
def active_bot?
|
# Assignment V2 methods
|
||||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
def assignment_v2_enabled?
|
||||||
status: 'enabled').count.positive?
|
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_type
|
def auto_assignment_enabled?
|
||||||
channel.name
|
if assignment_v2_enabled?
|
||||||
end
|
assignment_policy.present? && assignment_policy.enabled?
|
||||||
|
else
|
||||||
def webhook_data
|
enable_auto_assignment?
|
||||||
{
|
|
||||||
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
|
end
|
||||||
|
|
||||||
def member_ids_with_assignment_capacity
|
# Returns inbox members who are available for assignment
|
||||||
members.ids
|
# 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def default_name_for_blank_name
|
def build_online_agents_scope(online_agent_ids)
|
||||||
email? ? display_name_from_email : ''
|
inbox_members
|
||||||
|
.joins(:user)
|
||||||
|
.where(users: { id: online_agent_ids })
|
||||||
|
.includes(:user)
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_sanitization_rules(name)
|
def apply_agent_filters(scope, options)
|
||||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
# Exclude specific users if requested
|
||||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
|
||||||
.gsub(/\s+/, ' ') # Normalize spaces
|
# Apply capacity filtering for enterprise accounts
|
||||||
.strip
|
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
|
end
|
||||||
|
|
||||||
def display_name_from_email
|
def fetch_online_agent_ids
|
||||||
channel.email.split('@').first.parameterize.titleize
|
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
|
end
|
||||||
|
|
||||||
def dispatch_create_event
|
def dispatch_create_event
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -191,3 +191,7 @@
|
|||||||
display_name: CRM V2
|
display_name: CRM V2
|
||||||
enabled: false
|
enabled: false
|
||||||
chatwoot_internal: true
|
chatwoot_internal: true
|
||||||
|
- name: enterprise_agent_capacity
|
||||||
|
display_name: Enterprise Agent Capacity
|
||||||
|
enabled: true
|
||||||
|
premium: true
|
||||||
|
|||||||
+27
-2
@@ -217,6 +217,33 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
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
|
namespace :twitter do
|
||||||
resource :authorization, only: [:create]
|
resource :authorization, only: [:create]
|
||||||
end
|
end
|
||||||
@@ -290,8 +317,6 @@ Rails.application.routes.draw do
|
|||||||
member do
|
member do
|
||||||
patch :archive
|
patch :archive
|
||||||
delete :logo
|
delete :logo
|
||||||
post :send_instructions
|
|
||||||
get :ssl_status
|
|
||||||
end
|
end
|
||||||
resources :categories
|
resources :categories
|
||||||
resources :articles do
|
resources :articles do
|
||||||
|
|||||||
@@ -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: 100
|
||||||
|
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
|
||||||
|
add_index :assignment_policies, :enabled
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateAgentCapacityPolicies < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :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
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateInboxCapacityLimits < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :inbox_capacity_limits do |t|
|
||||||
|
t.references :agent_capacity_policy, null: false, index: true
|
||||||
|
t.references :inbox, null: false, index: true
|
||||||
|
t.integer :conversation_limit, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :inbox_capacity_limits, [:agent_capacity_policy_id, :inbox_id], unique: true
|
||||||
|
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,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateLeaves < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :leaves do |t|
|
||||||
|
t.references :account, null: false
|
||||||
|
t.references :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, [:account_id, :status]
|
||||||
|
end
|
||||||
|
end
|
||||||
+67
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
|
ActiveRecord::Schema[7.1].define(version: 2025_08_06_140005) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -39,8 +39,10 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
|
|||||||
t.integer "availability", default: 0, null: false
|
t.integer "availability", default: 0, null: false
|
||||||
t.boolean "auto_offline", default: true, null: false
|
t.boolean "auto_offline", default: true, null: false
|
||||||
t.bigint "custom_role_id"
|
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", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
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 ["custom_role_id"], name: "index_account_users_on_custom_role_id"
|
||||||
t.index ["user_id"], name: "index_account_users_on_user_id"
|
t.index ["user_id"], name: "index_account_users_on_user_id"
|
||||||
end
|
end
|
||||||
@@ -120,6 +122,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
|
|||||||
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
t.index ["account_id"], name: "index_agent_bots_on_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "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: "index_agent_capacity_policies_on_account_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "applied_slas", force: :cascade do |t|
|
create_table "applied_slas", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.bigint "sla_policy_id", null: false
|
t.bigint "sla_policy_id", null: false
|
||||||
@@ -169,6 +181,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
|
|||||||
t.index ["views"], name: "index_articles_on_views"
|
t.index ["views"], name: "index_articles_on_views"
|
||||||
end
|
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: 100, 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: "index_assignment_policies_on_account_id_and_name", 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|
|
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||||
t.integer "file_type", default: 0
|
t.integer "file_type", default: 0
|
||||||
t.string "external_url"
|
t.string "external_url"
|
||||||
@@ -728,6 +756,26 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
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"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "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: "idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf", unique: true
|
||||||
|
t.index ["agent_capacity_policy_id"], name: "index_inbox_capacity_limits_on_agent_capacity_policy_id"
|
||||||
|
t.index ["inbox_id"], name: "index_inbox_capacity_limits_on_inbox_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
create_table "inbox_members", id: :serial, force: :cascade do |t|
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.integer "inbox_id", null: false
|
t.integer "inbox_id", null: false
|
||||||
@@ -800,6 +848,24 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_05_160307) do
|
|||||||
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "leaves", force: :cascade do |t|
|
||||||
|
t.bigint "account_id", null: false
|
||||||
|
t.bigint "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_id_and_status"
|
||||||
|
t.index ["account_id"], name: "index_leaves_on_account_id"
|
||||||
|
t.index ["approved_by_id"], name: "index_leaves_on_approved_by_id"
|
||||||
|
t.index ["user_id"], name: "index_leaves_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "macros", force: :cascade do |t|
|
create_table "macros", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.string "name", 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,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) }
|
$alfred.with { |conn| conn.hmget(key, *fields) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# get all fields and values from redis hash
|
||||||
|
def hgetall(key)
|
||||||
|
$alfred.with { |conn| conn.hgetall(key) }
|
||||||
|
end
|
||||||
|
|
||||||
# sorted set operations
|
# sorted set operations
|
||||||
|
|
||||||
# add score and value for a key
|
# add score and value for a key
|
||||||
@@ -100,5 +105,15 @@ module Redis::Alfred
|
|||||||
def zremrangebyscore(key, range_start, range_end)
|
def zremrangebyscore(key, range_start, range_end)
|
||||||
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
|
$alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) }
|
||||||
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
|
||||||
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
|
||||||
Reference in New Issue
Block a user