diff --git a/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb b/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb new file mode 100644 index 000000000..d89222fbd --- /dev/null +++ b/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index f8eb998f0..4aa1c4b29 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -28,6 +28,7 @@ class Account < ApplicationRecord include Reportable include Featurable include CacheKeys + include AssignmentV2FeatureFlag SETTINGS_PARAMS_SCHEMA = { 'type': 'object', @@ -97,6 +98,11 @@ class Account < ApplicationRecord has_many :webhooks, dependent: :destroy_async has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp' has_many :working_hours, dependent: :destroy_async + has_many :leaves, dependent: :destroy_async, class_name: 'Leave' + + # Assignment V2 associations + has_many :assignment_policies, dependent: :destroy_async + has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy' if ChatwootApp.enterprise? has_one_attached :contacts_export diff --git a/app/models/account_user.rb b/app/models/account_user.rb index bbcb0e010..5f540d9ad 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -30,6 +30,9 @@ class AccountUser < ApplicationRecord belongs_to :account belongs_to :user belongs_to :inviter, class_name: 'User', optional: true + belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true + + has_many :leaves, dependent: :destroy, class_name: 'Leave' enum role: { agent: 0, administrator: 1 } enum availability: { online: 0, offline: 1, busy: 2 } diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 1c898ba7f..e5348867e 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -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] :exclude_user_ids Users to exclude from results + # + # @return [ActiveRecord::Relation] 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 diff --git a/app/policies/agent_capacity_policy_policy.rb b/app/policies/agent_capacity_policy_policy.rb new file mode 100644 index 000000000..b9f7f46d3 --- /dev/null +++ b/app/policies/agent_capacity_policy_policy.rb @@ -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 diff --git a/config/features.yml b/config/features.yml index db2d46700..c5cf8a099 100644 --- a/config/features.yml +++ b/config/features.yml @@ -191,3 +191,7 @@ display_name: CRM V2 enabled: false chatwoot_internal: true +- name: enterprise_agent_capacity + display_name: Enterprise Agent Capacity + enabled: true + premium: true diff --git a/config/routes.rb b/config/routes.rb index 7fb348084..81715922c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -217,6 +217,33 @@ Rails.application.routes.draw do end end + resources :leaves do + member do + post :approve + post :reject + end + end + + # Assignment V2 Routes + resources :assignment_policies + + resources :inboxes, only: [] do + resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies' + end + + # Agent Capacity Management (Enterprise) + resources :agent_capacity_policies do + member do + post 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#set_inbox_limit' + delete 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#remove_inbox_limit' + post 'users', to: 'agent_capacity_policies#assign_user' + delete 'users/:user_id', to: 'agent_capacity_policies#remove_user' + end + end + + # Agent capacity status + get 'agents/:agent_id/capacity', to: 'agent_capacity_policies#agent_capacity' + namespace :twitter do resource :authorization, only: [:create] end @@ -290,8 +317,6 @@ Rails.application.routes.draw do member do patch :archive delete :logo - post :send_instructions - get :ssl_status end resources :categories resources :articles do diff --git a/enterprise/app/models/enterprise/agent_capacity_policy.rb b/enterprise/app/models/enterprise/agent_capacity_policy.rb new file mode 100644 index 000000000..fce610ae4 --- /dev/null +++ b/enterprise/app/models/enterprise/agent_capacity_policy.rb @@ -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 diff --git a/enterprise/app/models/enterprise/agent_capacity_policy_user.rb b/enterprise/app/models/enterprise/agent_capacity_policy_user.rb new file mode 100644 index 000000000..a6f9251a3 --- /dev/null +++ b/enterprise/app/models/enterprise/agent_capacity_policy_user.rb @@ -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 diff --git a/enterprise/app/models/enterprise/inbox_capacity_limit.rb b/enterprise/app/models/enterprise/inbox_capacity_limit.rb new file mode 100644 index 000000000..ae12ebed6 --- /dev/null +++ b/enterprise/app/models/enterprise/inbox_capacity_limit.rb @@ -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 diff --git a/enterprise/app/policies/enterprise/agent_capacity_policy_policy.rb b/enterprise/app/policies/enterprise/agent_capacity_policy_policy.rb new file mode 100644 index 000000000..f87d29afe --- /dev/null +++ b/enterprise/app/policies/enterprise/agent_capacity_policy_policy.rb @@ -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 diff --git a/enterprise/app/services/enterprise/assignment_v2/capacity_service.rb b/enterprise/app/services/enterprise/assignment_v2/capacity_service.rb new file mode 100644 index 000000000..c2863f40f --- /dev/null +++ b/enterprise/app/services/enterprise/assignment_v2/capacity_service.rb @@ -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 diff --git a/lib/redis/alfred.rb b/lib/redis/alfred.rb index b3e156420..b26c53073 100644 --- a/lib/redis/alfred.rb +++ b/lib/redis/alfred.rb @@ -78,6 +78,11 @@ module Redis::Alfred $alfred.with { |conn| conn.hmget(key, *fields) } end + # get all fields and values from redis hash + def hgetall(key) + $alfred.with { |conn| conn.hgetall(key) } + end + # sorted set operations # add score and value for a key @@ -100,5 +105,15 @@ module Redis::Alfred def zremrangebyscore(key, range_start, range_end) $alfred.with { |conn| conn.zremrangebyscore(key, range_start, range_end) } end + + # transaction operations + + def multi(&) + $alfred.with { |conn| conn.multi(&) } + end + + def expire(key, expiry) + $alfred.with { |conn| conn.expire(key, expiry) } + end end end diff --git a/spec/factories/enterprise_agent_capacity_policies.rb b/spec/factories/enterprise_agent_capacity_policies.rb new file mode 100644 index 000000000..4dbd55319 --- /dev/null +++ b/spec/factories/enterprise_agent_capacity_policies.rb @@ -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 diff --git a/spec/factories/enterprise_inbox_capacity_limits.rb b/spec/factories/enterprise_inbox_capacity_limits.rb new file mode 100644 index 000000000..0fec0ccc0 --- /dev/null +++ b/spec/factories/enterprise_inbox_capacity_limits.rb @@ -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