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..c8ea2d2ae --- /dev/null +++ b/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb @@ -0,0 +1,183 @@ +# 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 = Current.account.agent_capacity_policies.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 = Current.account.agent_capacity_policies.build(agent_capacity_policy_params) + + 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]) + + # Remove user from any existing capacity policy + Enterprise::AgentCapacityPolicyUser.where(user: user).destroy_all + + # Assign to new policy + policy_user = @agent_capacity_policy.agent_capacity_policy_users.build(user: user) + + if policy_user.save + render json: { + message: 'User assigned successfully', + agent_capacity_policy_user: serialize_policy_user(policy_user) + } + else + render json: { errors: policy_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]) + policy_user = @agent_capacity_policy.agent_capacity_policy_users.find_by(user: user) + + if policy_user + if policy_user.destroy + head :ok + else + render json: { errors: policy_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 + unless Current.account.feature_enabled?(:enterprise_agent_capacity) + render json: { + error: 'Agent capacity policies are only available for enterprise accounts' + }, status: :forbidden + end + end + + def fetch_agent_capacity_policy + @agent_capacity_policy = Current.account.agent_capacity_policies.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, + 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 + + def serialize_policy_user(policy_user) + { + id: policy_user.id, + user_id: policy_user.user_id, + user_name: policy_user.user.name, + user_email: policy_user.user.email, + created_at: policy_user.created_at + } + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/assignment_metrics_controller.rb b/app/controllers/api/v1/accounts/assignment_metrics_controller.rb new file mode 100644 index 000000000..97724565d --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_metrics_controller.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::AssignmentMetricsController < Api::V1::Accounts::BaseController + before_action :check_authorization + before_action :validate_date_range + + def index + @metrics = compute_assignment_metrics + render json: { assignment_metrics: @metrics } + end + + def agent_history + @agent = Current.account.users.find(params[:agent_id]) + @assignment_history = fetch_agent_assignment_history(@agent) + + render json: { + agent: serialize_agent(@agent), + assignment_history: @assignment_history, + meta: pagination_meta + } + end + + private + + def compute_assignment_metrics + metrics = { + summary: compute_summary_metrics, + by_period: compute_period_metrics, + by_inbox: compute_inbox_metrics, + by_agent: compute_agent_metrics, + by_policy: compute_policy_metrics + } + + metrics + end + + def compute_summary_metrics + conversations = filter_conversations_by_date_range + + { + total_assignments: conversations.count, + average_assignment_time: calculate_average_assignment_time(conversations), + assignments_per_agent: calculate_assignments_per_agent(conversations), + unassigned_conversations: Current.account.conversations.open.unassigned.count, + policies_active: Current.account.assignment_policies.enabled.count + } + end + + def compute_period_metrics + group_by = params[:group_by] || 'day' + conversations = filter_conversations_by_date_range + + case group_by + when 'hour' + group_by_hour(conversations) + when 'day' + group_by_day(conversations) + when 'week' + group_by_week(conversations) + when 'month' + group_by_month(conversations) + else + group_by_day(conversations) + end + end + + def compute_inbox_metrics + inbox_id = params[:inbox_id] + base_query = filter_conversations_by_date_range + + if inbox_id.present? + base_query = base_query.where(inbox_id: inbox_id) + end + + base_query.joins(:inbox) + .group('inboxes.id', 'inboxes.name') + .count + .map { |k, v| { inbox_id: k[0], inbox_name: k[1], assignment_count: v } } + end + + def compute_agent_metrics + agent_id = params[:agent_id] + base_query = filter_conversations_by_date_range.where.not(assignee_id: nil) + + if agent_id.present? + base_query = base_query.where(assignee_id: agent_id) + end + + base_query.joins(:assignee) + .group('users.id', 'users.name', 'users.email') + .count + .map { |k, v| { agent_id: k[0], agent_name: k[1], agent_email: k[2], assignment_count: v } } + .sort_by { |a| -a[:assignment_count] } + end + + def compute_policy_metrics + # Get metrics grouped by assignment policy + policy_metrics = {} + + Current.account.assignment_policies.includes(:inboxes).each do |policy| + inbox_ids = policy.inboxes.pluck(:id) + next if inbox_ids.empty? + + conversations = filter_conversations_by_date_range.where(inbox_id: inbox_ids) + + policy_metrics[policy.id] = { + policy_id: policy.id, + policy_name: policy.name, + assignment_order: policy.assignment_order, + total_assignments: conversations.count, + average_assignment_time: calculate_average_assignment_time(conversations), + inbox_count: inbox_ids.count + } + end + + policy_metrics.values + end + + def fetch_agent_assignment_history(agent) + conversations = agent.assigned_conversations + .includes(:inbox, :contact) + .where(created_at: date_range) + .order(created_at: :desc) + .page(params[:page]) + .per(params[:per_page] || 50) + + conversations.map do |conversation| + { + conversation_id: conversation.id, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox.name, + contact_name: conversation.contact.name, + assigned_at: conversation.assignee_last_seen_at || conversation.created_at, + status: conversation.status, + created_at: conversation.created_at + } + end + end + + def filter_conversations_by_date_range + Current.account.conversations.where(created_at: date_range) + end + + def date_range + start_date = params[:start_date] ? Date.parse(params[:start_date]).beginning_of_day : 30.days.ago + end_date = params[:end_date] ? Date.parse(params[:end_date]).end_of_day : Time.current + + start_date..end_date + end + + def validate_date_range + if params[:start_date].present? && params[:end_date].present? + start_date = Date.parse(params[:start_date]) + end_date = Date.parse(params[:end_date]) + + if start_date > end_date + render json: { error: 'Start date must be before end date' }, status: :bad_request + elsif (end_date - start_date).to_i > 365 + render json: { error: 'Date range cannot exceed 365 days' }, status: :bad_request + end + end + rescue Date::Error + render json: { error: 'Invalid date format' }, status: :bad_request + end + + def calculate_average_assignment_time(conversations) + assigned_conversations = conversations.where.not(assignee_id: nil) + return 0 if assigned_conversations.empty? + + total_time = assigned_conversations.sum do |conv| + assignment_time = conv.assignee_last_seen_at || conv.updated_at + (assignment_time - conv.created_at).to_i + end + + (total_time / assigned_conversations.count / 60).round(2) # Return in minutes + end + + def calculate_assignments_per_agent(conversations) + assigned_count = conversations.where.not(assignee_id: nil).count + agent_count = conversations.where.not(assignee_id: nil).distinct.count(:assignee_id) + + return 0 if agent_count.zero? + + (assigned_count.to_f / agent_count).round(2) + end + + def group_by_hour(conversations) + conversations.group_by_hour(:created_at).count + end + + def group_by_day(conversations) + conversations.group_by_day(:created_at).count + end + + def group_by_week(conversations) + conversations.group_by_week(:created_at).count + end + + def group_by_month(conversations) + conversations.group_by_month(:created_at).count + end + + def serialize_agent(agent) + { + id: agent.id, + name: agent.name, + email: agent.email, + avatar_url: agent.avatar_url + } + end + + def pagination_meta + { + current_page: params[:page] || 1, + per_page: params[:per_page] || 50 + } + end + + def check_authorization + authorize(Conversation, :index?) + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb new file mode 100644 index 000000000..0eec2f377 --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy, only: [:show, :update, :destroy] + before_action :check_authorization + + def index + @assignment_policies = Current.account.assignment_policies.includes(:inboxes) + render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) } + end + + def show + render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) } + end + + def create + @assignment_policy = Current.account.assignment_policies.build(assignment_policy_params) + + if @assignment_policy.save + render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created + else + render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity + end + end + + def update + if @assignment_policy.update(assignment_policy_params) + render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) } + else + render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity + end + end + + def destroy + if @assignment_policy.destroy + head :ok + else + render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(params[:id]) + end + + def assignment_policy_params + params.require(:assignment_policy).permit( + :name, :description, :assignment_order, :conversation_priority, + :fair_distribution_limit, :fair_distribution_window, :enabled + ) + end + + def serialize_assignment_policy(policy) + { + id: policy.id, + name: policy.name, + description: policy.description, + assignment_order: policy.assignment_order, + conversation_priority: policy.conversation_priority, + fair_distribution_limit: policy.fair_distribution_limit, + fair_distribution_window: policy.fair_distribution_window, + enabled: policy.enabled, + inbox_count: policy.inboxes.count, + created_at: policy.created_at, + updated_at: policy.updated_at + } + end + + def serialize_assignment_policies(policies) + policies.map { |policy| serialize_assignment_policy(policy) } + end + + def check_authorization + authorize(AssignmentPolicy) + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/inbox_assignment_policies_controller.rb b/app/controllers/api/v1/accounts/inbox_assignment_policies_controller.rb new file mode 100644 index 000000000..554b6599b --- /dev/null +++ b/app/controllers/api/v1/accounts/inbox_assignment_policies_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_inbox + before_action :check_authorization + + def show + @inbox_assignment_policy = @inbox.inbox_assignment_policy + + if @inbox_assignment_policy + render json: { + inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy) + } + else + render json: { + inbox_assignment_policy: nil, + message: 'No assignment policy assigned to this inbox' + } + end + end + + def create + # Remove existing assignment if any + @inbox.inbox_assignment_policy&.destroy + + @assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id]) + @inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy) + + if @inbox_assignment_policy.save + render json: { + inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy) + }, status: :created + else + render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity + end + end + + def destroy + @inbox_assignment_policy = @inbox.inbox_assignment_policy + + if @inbox_assignment_policy + if @inbox_assignment_policy.destroy + head :ok + else + render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity + end + else + render json: { error: 'No assignment policy found for this inbox' }, status: :not_found + end + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + end + + def check_authorization + authorize(@inbox, :update?) + end + + def serialize_inbox_assignment_policy(inbox_assignment_policy) + { + id: inbox_assignment_policy.id, + inbox_id: inbox_assignment_policy.inbox_id, + assignment_policy_id: inbox_assignment_policy.assignment_policy_id, + assignment_policy: { + id: inbox_assignment_policy.assignment_policy.id, + name: inbox_assignment_policy.assignment_policy.name, + description: inbox_assignment_policy.assignment_policy.description, + assignment_order: inbox_assignment_policy.assignment_policy.assignment_order, + conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority, + fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit, + fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window, + enabled: inbox_assignment_policy.assignment_policy.enabled + }, + created_at: inbox_assignment_policy.created_at, + updated_at: inbox_assignment_policy.updated_at + } + end +end \ No newline at end of file diff --git a/app/models/account.rb b/app/models/account.rb index e1271cf7f..ab65fdca2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -102,6 +102,7 @@ class Account < ApplicationRecord # 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/concerns/assignment_v2_feature_flag.rb b/app/models/concerns/assignment_v2_feature_flag.rb new file mode 100644 index 000000000..7040fd963 --- /dev/null +++ b/app/models/concerns/assignment_v2_feature_flag.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module AssignmentV2FeatureFlag + extend ActiveSupport::Concern + + def assignment_v2_enabled? + # Check account-level feature flag + return false unless feature_enabled_for_account? + + # Check for any inbox-level overrides + return false if inbox_level_override_disabled? + + # Check system-wide killswitch + !GlobalConfig.get('assignment_v2_disabled', false) + end + + private + + def feature_enabled_for_account? + config = GlobalConfig.get('assignment_v2') + return false unless config&.dig('enabled') + + # If no account allowlist, enable for all + allowed_accounts = config.dig('accounts') + return true if allowed_accounts.blank? + + # Check if account is in allowlist + allowed_accounts.include?(self.id) + end + + def inbox_level_override_disabled? + return false unless respond_to?(:id) + + # Allow per-inbox disabling during migration + GlobalConfig.get("assignment_v2_disabled_inboxes", []).include?(self.id) + end +end \ No newline at end of file diff --git a/app/policies/assignment_policy_policy.rb b/app/policies/assignment_policy_policy.rb new file mode 100644 index 000000000..1c5953c28 --- /dev/null +++ b/app/policies/assignment_policy_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AssignmentPolicyPolicy < ApplicationPolicy + def index? + @account_user.administrator? + end + + def show? + @account_user.administrator? + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index fd368d4f3..f7f496326 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -224,6 +224,30 @@ Rails.application.routes.draw do 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' + + # Assignment Metrics + get 'reports/assignment_metrics', to: 'assignment_metrics#index' + get 'agents/:agent_id/assignment_history', to: 'assignment_metrics#agent_history' + namespace :twitter do resource :authorization, only: [:create] 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..d54d9ae43 --- /dev/null +++ b/enterprise/app/models/enterprise/agent_capacity_policy_user.rb @@ -0,0 +1,47 @@ +# 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) +# + +module Enterprise + class AgentCapacityPolicyUser < ApplicationRecord + self.table_name = 'enterprise_agent_capacity_policy_users' + + # Associations + belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy' + belongs_to :user + + # Validations + validates :user_id, uniqueness: true + + # Delegations + delegate :account, to: :agent_capacity_policy + + # Callbacks + after_create_commit :invalidate_user_cache + after_destroy_commit :invalidate_user_cache + + private + + def invalidate_user_cache + Rails.cache.delete_matched("assignment_v2:capacity:#{user_id}:*") + end + end +end \ No newline at end of file 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..5fe4650e3 --- /dev/null +++ b/enterprise/app/policies/enterprise/agent_capacity_policy_policy.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Enterprise + class 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 +end \ No newline at end of file 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..dbf848c48 --- /dev/null +++ b/enterprise/app/services/enterprise/assignment_v2/capacity_service.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +module Enterprise + module AssignmentV2 + class 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 + + inboxes_data = [] + total_current = 0 + total_limit = 0 + + policy.inbox_capacity_limits.includes(:inbox).each do |inbox_limit| + inbox = inbox_limit.inbox + current = count_current_assignments(agent, inbox, policy) + limit = inbox_limit.conversation_limit + + total_current += current + total_limit += limit + + inboxes_data << { + inbox_id: inbox.id, + inbox_name: inbox.name, + current_assignments: current, + conversation_limit: limit, + available_capacity: [limit - current, 0].max + } + end + + { + policy_id: policy.id, + policy_name: policy.name, + total_current_assignments: total_current, + total_conversation_limit: total_limit, + total_available_capacity: [total_limit - total_current, 0].max, + exclusion_rules: policy.exclusion_rules, + inboxes: inboxes_data + } + end + + private + + 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 + end +end \ No newline at end of file