diff --git a/config/routes.rb b/config/routes.rb index 89632c18f..a437443f9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,9 @@ Rails.application.routes.draw do resource :bulk_actions, only: [:create] resources :agents, only: [:index, :create, :update, :destroy] do post :bulk_create, on: :collection + member do + get 'capacity', to: 'agents/capacity#show' + end end namespace :captain do resources :assistants do @@ -97,6 +100,13 @@ Rails.application.routes.draw do end resources :sla_policies, only: [:index, :create, :show, :update, :destroy] resources :custom_roles, only: [:index, :create, :show, :update, :destroy] + resources :agent_capacity_policies, only: [:index, :create, :show, :update, :destroy] do + member do + post 'users', to: 'agent_capacity_policies#assign_user' + delete 'users/:user_id', to: 'agent_capacity_policies#unassign_user' + put 'inbox_limits/:inbox_id', to: 'agent_capacity_policies#update_inbox_limit' + end + end resources :campaigns, only: [:index, :create, :show, :update, :destroy] resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy] namespace :channels do diff --git a/db/schema.rb b/db/schema.rb index 7f44c373c..58ecf5bea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -320,6 +320,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "status", default: 0, null: false + t.jsonb "metadata", default: {} t.index ["account_id"], name: "index_captain_documents_on_account_id" t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id" diff --git a/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb b/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb new file mode 100644 index 000000000..f7c7e55a8 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/agent_capacity_policies_controller.rb @@ -0,0 +1,58 @@ +class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::EnterpriseAccountsController + before_action :fetch_policy, only: [:show, :update, :destroy, :assign_user, :unassign_user, :update_inbox_limit] + before_action :check_enterprise_authorization + + def index + @agent_capacity_policies = Current.account.agent_capacity_policies + end + + def show; end + + def create + @agent_capacity_policy = Current.account.agent_capacity_policies.create!(permitted_params) + end + + def update + @agent_capacity_policy.update!(permitted_params) + end + + def destroy + @agent_capacity_policy.destroy! + head :ok + end + + def assign_user + user = Current.account.users.find(params[:user_id]) + account_user = Current.account.account_users.find_by!(user: user) + account_user.update!(agent_capacity_policy: @agent_capacity_policy) + render json: { message: 'User assigned successfully' } + end + + def unassign_user + user = Current.account.users.find(params[:user_id]) + account_user = Current.account.account_users.find_by!(user: user) + account_user.update!(agent_capacity_policy: nil) + render json: { message: 'User unassigned successfully' } + end + + def update_inbox_limit + inbox = Current.account.inboxes.find(params[:inbox_id]) + inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox) + inbox_limit.update!(conversation_limit: params[:conversation_limit]) + render json: inbox_limit + end + + private + + def permitted_params + params.require(:agent_capacity_policy).permit(:name, :description, exclusion_rules: {}) + end + + def fetch_policy + @agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:id]) + end + + def check_enterprise_authorization + authorize(Enterprise::AgentCapacityPolicy) + end +end diff --git a/enterprise/app/controllers/api/v1/accounts/agents/capacity_controller.rb b/enterprise/app/controllers/api/v1/accounts/agents/capacity_controller.rb new file mode 100644 index 000000000..68a76e511 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/agents/capacity_controller.rb @@ -0,0 +1,39 @@ +class Api::V1::Accounts::Agents::CapacityController < Api::V1::Accounts::EnterpriseAccountsController + before_action :fetch_agent + + def show + account_user = Current.account.account_users.find_by!(user: @agent) + capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user) + inbox = fetch_inbox + + render json: build_capacity_response(account_user, capacity_service, inbox) + end + + private + + def fetch_agent + @agent = Current.account.users.find(params[:id]) + end + + def fetch_inbox + return if params[:inbox_id].blank? + + Current.account.inboxes.find(params[:inbox_id]) + end + + def build_capacity_response(account_user, capacity_service, inbox) + response = { + has_capacity: capacity_service.agent_has_capacity?(inbox), + overall_capacity: capacity_service.agent_overall_capacity, + inbox_capacity: inbox ? capacity_service.agent_capacity_for_inbox(inbox) : nil, + current_conversations_count: account_user.user.assigned_conversations.open.count + } + + add_inbox_conversations_count(response, account_user, inbox) if inbox + response + end + + def add_inbox_conversations_count(response, account_user, inbox) + response[:inbox_conversations_count] = account_user.user.assigned_conversations.open.where(inbox: inbox).count + end +end 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..798ac4af3 --- /dev/null +++ b/enterprise/app/models/enterprise/agent_capacity_policy.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Enterprise::AgentCapacityPolicy < ApplicationRecord + self.table_name = 'agent_capacity_policies' + + belongs_to :account, class_name: '::Account' + has_many :inbox_capacity_limits, class_name: 'Enterprise::InboxCapacityLimit', dependent: :destroy + has_many :inboxes, through: :inbox_capacity_limits, class_name: '::Inbox' + has_many :account_users, class_name: '::AccountUser', dependent: :nullify + + validates :name, presence: true, length: { maximum: 255 } + + def applicable_for_time?(time = Time.current) + return true if exclusion_rules.blank? + + !excluded_for_time?(time) + end + + def capacity_for_inbox(inbox) + inbox_capacity_limits.find_by(inbox: inbox)&.conversation_limit + end + + def overall_capacity + exclusion_rules['overall_capacity'] || Float::INFINITY + end + + private + + def excluded_for_time?(time) + excluded_by_hours?(time) || excluded_by_days?(time) + end + + def excluded_by_hours?(time) + return false if exclusion_rules['hours'].blank? + + current_hour = time.hour + exclusion_rules['hours'].include?(current_hour) + end + + def excluded_by_days?(time) + return false if exclusion_rules['days'].blank? + + current_day = time.strftime('%A').downcase + exclusion_rules['days'].include?(current_day) + end +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index c31b6c10e..0f40a2f64 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -5,6 +5,7 @@ module Enterprise::Concerns::Account has_many :sla_policies, dependent: :destroy_async has_many :applied_slas, dependent: :destroy_async has_many :custom_roles, dependent: :destroy_async + has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy' has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' diff --git a/enterprise/app/models/enterprise/concerns/account_user.rb b/enterprise/app/models/enterprise/concerns/account_user.rb index 0887d31d9..96cacaa19 100644 --- a/enterprise/app/models/enterprise/concerns/account_user.rb +++ b/enterprise/app/models/enterprise/concerns/account_user.rb @@ -3,5 +3,6 @@ module Enterprise::Concerns::AccountUser included do belongs_to :custom_role, optional: true + belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true end end diff --git a/enterprise/app/models/enterprise/concerns/inbox_agent_availability.rb b/enterprise/app/models/enterprise/concerns/inbox_agent_availability.rb new file mode 100644 index 000000000..bf14da4ce --- /dev/null +++ b/enterprise/app/models/enterprise/concerns/inbox_agent_availability.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Enterprise::Concerns::InboxAgentAvailability + def apply_agent_filters(scope, options) + scope = super(scope, options) + + # Apply capacity filtering if requested + scope = filter_by_capacity(scope) if options[:check_capacity] + + scope + end + + private + + def filter_by_capacity(inbox_members_scope) + return inbox_members_scope unless assignment_policy&.enabled? + + inbox_members_scope.select do |inbox_member| + account_user = AccountUser.find_by(account: account, user: inbox_member.user) + next true if account_user&.agent_capacity_policy.blank? + + capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user) + capacity_service.agent_has_capacity?(self) + end + end +end diff --git a/enterprise/app/models/enterprise/inbox.rb b/enterprise/app/models/enterprise/inbox.rb index 0ae21ce00..42c3c6c0e 100644 --- a/enterprise/app/models/enterprise/inbox.rb +++ b/enterprise/app/models/enterprise/inbox.rb @@ -2,9 +2,17 @@ module Enterprise::Inbox def member_ids_with_assignment_capacity return super unless enable_auto_assignment? - max_assignment_limit = auto_assignment_config['max_assignment_limit'] - overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : [] - super - overloaded_agent_ids + member_ids = apply_max_assignment_limit(super) + apply_capacity_policy_filter(member_ids) + end + + def available_agents(options = {}) + agents = super(options) + + # Apply capacity filtering if requested and assignment policy is enabled + agents = filter_agents_by_capacity(agents) if options[:check_capacity] && assignment_policy&.enabled? + + agents end def active_bot? @@ -17,6 +25,36 @@ module Enterprise::Inbox private + def filter_agents_by_capacity(inbox_members_scope) + inbox_members_scope.select do |inbox_member| + account_user = AccountUser.find_by(account: account, user: inbox_member.user) + next true if account_user&.agent_capacity_policy.blank? + + capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user) + capacity_service.agent_has_capacity?(self) + end + end + + def apply_max_assignment_limit(member_ids) + max_assignment_limit = auto_assignment_config['max_assignment_limit'] + return member_ids if max_assignment_limit.blank? + + overloaded_agent_ids = get_agent_ids_over_assignment_limit(max_assignment_limit) + member_ids - overloaded_agent_ids + end + + def apply_capacity_policy_filter(member_ids) + return member_ids unless assignment_policy&.enabled? + + account_users = AccountUser.where(account_id: account_id, user_id: member_ids) + account_users.select do |account_user| + next true if account_user.agent_capacity_policy.blank? + + capacity_service = Enterprise::AssignmentV2::CapacityService.new(account_user) + capacity_service.agent_has_capacity?(self) + end.map(&:user_id) + end + def more_responses? account.usage_limits[:captain][:responses][:current_available].positive? 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..ca12eb253 --- /dev/null +++ b/enterprise/app/models/enterprise/inbox_capacity_limit.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Enterprise::InboxCapacityLimit < ApplicationRecord + self.table_name = 'inbox_capacity_limits' + + belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy' + belongs_to :inbox, class_name: '::Inbox' + + validates :conversation_limit, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id } +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..d1423ae42 --- /dev/null +++ b/enterprise/app/policies/enterprise/agent_capacity_policy_policy.rb @@ -0,0 +1,33 @@ +class Enterprise::AgentCapacityPolicyPolicy < ApplicationPolicy + def index? + @account_user.administrator? || @account_user.agent? + end + + def update? + @account_user.administrator? + end + + def show? + @account_user.administrator? || @account_user.agent? + end + + def create? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end + + def assign_user? + @account_user.administrator? + end + + def unassign_user? + @account_user.administrator? + end + + def update_inbox_limit? + @account_user.administrator? + end +end diff --git a/enterprise/app/services/enterprise/assignment_v2/assignment_service.rb b/enterprise/app/services/enterprise/assignment_v2/assignment_service.rb index ecbf72845..a6d9698c7 100644 --- a/enterprise/app/services/enterprise/assignment_v2/assignment_service.rb +++ b/enterprise/app/services/enterprise/assignment_v2/assignment_service.rb @@ -7,4 +7,19 @@ module Enterprise::AssignmentV2::AssignmentService super end end + + # Override find_agent_for_conversation to include capacity checks + def find_agent_for_conversation(_conversation) + available_agents = inbox.available_agents( + check_rate_limits: true, + check_capacity: true + ) + + if available_agents.empty? + log_no_agents_available + return nil + end + + selector_service.select_agent(available_agents) + 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..0196d4758 --- /dev/null +++ b/enterprise/app/services/enterprise/assignment_v2/capacity_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Enterprise::AssignmentV2::CapacityService + def initialize(account_user) + @account_user = account_user + @account = account_user.account + end + + def agent_has_capacity?(inbox = nil) + return true unless capacity_policy_applicable? + + if inbox + check_inbox_capacity(inbox) + else + check_overall_capacity + end + end + + def agent_capacity_for_inbox(inbox) + return Float::INFINITY unless capacity_policy_applicable? + + policy = @account_user.agent_capacity_policy + inbox_limit = policy.capacity_for_inbox(inbox) + return Float::INFINITY unless inbox_limit + + current_count = current_conversations_count(inbox) + [inbox_limit - current_count, 0].max + end + + def agent_overall_capacity + return Float::INFINITY unless capacity_policy_applicable? + + policy = @account_user.agent_capacity_policy + overall_limit = policy.overall_capacity + return Float::INFINITY if overall_limit == Float::INFINITY + + current_count = current_conversations_count + [overall_limit - current_count, 0].max + end + + def current_conversations_count(inbox = nil) + scope = @account_user.user.conversations + .joins(:account) + .where(account: @account, status: :open) + scope = scope.where(inbox: inbox) if inbox + scope.count + end + + private + + def capacity_policy_applicable? + return false if @account_user.agent_capacity_policy.blank? + + @account_user.agent_capacity_policy.applicable_for_time? + end + + def check_inbox_capacity(inbox) + policy = @account_user.agent_capacity_policy + inbox_limit = policy.capacity_for_inbox(inbox) + + return check_overall_capacity unless inbox_limit + + current_count = current_conversations_count(inbox) + current_count < inbox_limit && check_overall_capacity + end + + def check_overall_capacity + policy = @account_user.agent_capacity_policy + overall_limit = policy.overall_capacity + + return true if overall_limit == Float::INFINITY + + current_count = current_conversations_count + current_count < overall_limit + end +end diff --git a/enterprise/app/views/api/v1/accounts/agent_capacity_policies/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/create.json.jbuilder new file mode 100644 index 000000000..48b9052b8 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/create.json.jbuilder @@ -0,0 +1,12 @@ +json.id @agent_capacity_policy.id +json.name @agent_capacity_policy.name +json.description @agent_capacity_policy.description +json.exclusion_rules @agent_capacity_policy.exclusion_rules +json.created_at @agent_capacity_policy.created_at +json.updated_at @agent_capacity_policy.updated_at +json.account_id @agent_capacity_policy.account_id +json.inbox_capacity_limits @agent_capacity_policy.inbox_capacity_limits do |limit| + json.id limit.id + json.inbox_id limit.inbox_id + json.conversation_limit limit.conversation_limit +end diff --git a/enterprise/app/views/api/v1/accounts/agent_capacity_policies/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/index.json.jbuilder new file mode 100644 index 000000000..0551ce724 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/index.json.jbuilder @@ -0,0 +1,14 @@ +json.array! @agent_capacity_policies do |policy| + json.id policy.id + json.name policy.name + json.description policy.description + json.exclusion_rules policy.exclusion_rules + json.created_at policy.created_at + json.updated_at policy.updated_at + json.account_id policy.account_id + json.inbox_capacity_limits policy.inbox_capacity_limits do |limit| + json.id limit.id + json.inbox_id limit.inbox_id + json.conversation_limit limit.conversation_limit + end +end diff --git a/enterprise/app/views/api/v1/accounts/agent_capacity_policies/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/show.json.jbuilder new file mode 100644 index 000000000..48b9052b8 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/show.json.jbuilder @@ -0,0 +1,12 @@ +json.id @agent_capacity_policy.id +json.name @agent_capacity_policy.name +json.description @agent_capacity_policy.description +json.exclusion_rules @agent_capacity_policy.exclusion_rules +json.created_at @agent_capacity_policy.created_at +json.updated_at @agent_capacity_policy.updated_at +json.account_id @agent_capacity_policy.account_id +json.inbox_capacity_limits @agent_capacity_policy.inbox_capacity_limits do |limit| + json.id limit.id + json.inbox_id limit.inbox_id + json.conversation_limit limit.conversation_limit +end diff --git a/enterprise/app/views/api/v1/accounts/agent_capacity_policies/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/update.json.jbuilder new file mode 100644 index 000000000..48b9052b8 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/agent_capacity_policies/update.json.jbuilder @@ -0,0 +1,12 @@ +json.id @agent_capacity_policy.id +json.name @agent_capacity_policy.name +json.description @agent_capacity_policy.description +json.exclusion_rules @agent_capacity_policy.exclusion_rules +json.created_at @agent_capacity_policy.created_at +json.updated_at @agent_capacity_policy.updated_at +json.account_id @agent_capacity_policy.account_id +json.inbox_capacity_limits @agent_capacity_policy.inbox_capacity_limits do |limit| + json.id limit.id + json.inbox_id limit.inbox_id + json.conversation_limit limit.conversation_limit +end diff --git a/spec/enterprise/models/enterprise/agent_capacity_policy_spec.rb b/spec/enterprise/models/enterprise/agent_capacity_policy_spec.rb new file mode 100644 index 000000000..5eb4bd12a --- /dev/null +++ b/spec/enterprise/models/enterprise/agent_capacity_policy_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Enterprise::AgentCapacityPolicy do + let(:account) { create(:account) } + let(:policy) { described_class.create!(account: account, name: 'Test Policy') } + + describe 'associations' do + it 'belongs to account' do + expect(policy.account).to eq(account) + end + + it 'has many inbox capacity limits' do + expect(policy).to respond_to(:inbox_capacity_limits) + end + + it 'has many inboxes through inbox capacity limits' do + expect(policy).to respond_to(:inboxes) + end + + it 'has many account users' do + expect(policy).to respond_to(:account_users) + end + end + + describe 'validations' do + it 'validates presence of name' do + invalid_policy = described_class.new(account: account) + expect(invalid_policy).not_to be_valid + expect(invalid_policy.errors[:name]).to include("can't be blank") + end + + it 'validates length of name' do + invalid_policy = described_class.new(account: account, name: 'a' * 256) + expect(invalid_policy).not_to be_valid + expect(invalid_policy.errors[:name]).to include('is too long (maximum is 255 characters)') + end + end + + describe '#applicable_for_time?' do + context 'when no exclusion rules' do + it 'returns true' do + expect(policy.applicable_for_time?).to be true + end + end + + context 'with hour exclusions' do + let(:policy) do + described_class.create!( + account: account, + name: 'Hour Exclusion Policy', + exclusion_rules: { 'hours' => [10, 11, 12] } + ) + end + + it 'returns false during excluded hours' do + time = Time.zone.parse('10:30') + expect(policy.applicable_for_time?(time)).to be false + end + + it 'returns true outside excluded hours' do + time = Time.zone.parse('13:30') + expect(policy.applicable_for_time?(time)).to be true + end + end + + context 'with day exclusions' do + let(:policy) do + described_class.create!( + account: account, + name: 'Day Exclusion Policy', + exclusion_rules: { 'days' => %w[saturday sunday] } + ) + end + + it 'returns false on excluded days' do + time = Time.zone.parse('2024-01-06 10:00') # Saturday + expect(policy.applicable_for_time?(time)).to be false + end + + it 'returns true on non-excluded days' do + time = Time.zone.parse('2024-01-08 10:00') # Monday + expect(policy.applicable_for_time?(time)).to be true + end + end + end + + describe '#capacity_for_inbox' do + let(:inbox) { create(:inbox, account: account) } + + it 'returns the conversation limit for the inbox' do + Enterprise::InboxCapacityLimit.create!( + agent_capacity_policy: policy, + inbox: inbox, + conversation_limit: 10 + ) + expect(policy.capacity_for_inbox(inbox)).to eq(10) + end + + it 'returns nil for inbox without limit' do + other_inbox = create(:inbox, account: account) + expect(policy.capacity_for_inbox(other_inbox)).to be_nil + end + end + + describe '#overall_capacity' do + context 'when overall_capacity is set' do + let(:policy) do + described_class.create!( + account: account, + name: 'Overall Capacity Policy', + exclusion_rules: { 'overall_capacity' => 25 } + ) + end + + it 'returns the overall capacity' do + expect(policy.overall_capacity).to eq(25) + end + end + + context 'when overall_capacity is not set' do + it 'returns infinity' do + expect(policy.overall_capacity).to eq(Float::INFINITY) + end + end + end +end diff --git a/spec/enterprise/services/enterprise/assignment_v2/assignment_with_capacity_spec.rb b/spec/enterprise/services/enterprise/assignment_v2/assignment_with_capacity_spec.rb new file mode 100644 index 000000000..c3fc6f3bf --- /dev/null +++ b/spec/enterprise/services/enterprise/assignment_v2/assignment_with_capacity_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Assignment with Capacity' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } + + let(:agent1) { create(:user, accounts: [account]) } + let(:agent2) { create(:user, accounts: [account]) } + let(:account_user1) { AccountUser.find_by(account: account, user: agent1) } + let(:account_user2) { AccountUser.find_by(account: account, user: agent2) } + + let(:capacity_policy) do + Enterprise::AgentCapacityPolicy.create!( + account: account, + name: 'Test Capacity Policy', + exclusion_rules: { 'overall_capacity' => 5 } + ) + end + + let!(:inbox_capacity_limit) do + Enterprise::InboxCapacityLimit.create!( + agent_capacity_policy: capacity_policy, + inbox: inbox, + conversation_limit: 3 + ) + end + + before do + # Create and setup assignment policy for the inbox + @assignment_policy = create(:assignment_policy, account: account, enabled: true) + create(:inbox_assignment_policy, inbox: inbox, assignment_policy: @assignment_policy) + + # Add agents to inbox + create(:inbox_member, inbox: inbox, user: agent1) + create(:inbox_member, inbox: inbox, user: agent2) + + # Set agents online using presence and status + OnlineStatusTracker.update_presence(account.id, 'User', agent1.id) + OnlineStatusTracker.update_presence(account.id, 'User', agent2.id) + OnlineStatusTracker.set_status(account.id, agent1.id, 'online') + OnlineStatusTracker.set_status(account.id, agent2.id, 'online') + + # Also set account_user availability + account_user1.update!(availability: 'online') + account_user2.update!(availability: 'online') + + # Assign capacity policy to agent1 only + account_user1.update!(agent_capacity_policy: capacity_policy) + end + + describe 'capacity-based agent filtering' do + context 'when agent has capacity' do + it 'includes agent in available agents' do + available_agents = inbox.available_agents(check_capacity: true) + expect(available_agents.map(&:user)).to include(agent1, agent2) + end + end + + context 'when agent reaches inbox capacity limit' do + before do + # Create 3 conversations for agent1 (at inbox limit) + create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent1, status: :open) + end + + it 'excludes agent from available agents for that inbox' do + available_agents = inbox.available_agents(check_capacity: true) + expect(available_agents.map(&:user)).not_to include(agent1) + expect(available_agents.map(&:user)).to include(agent2) + end + end + + context 'when agent reaches overall capacity limit' do + before do + # Create 5 conversations for agent1 (at overall limit) + create_list(:conversation, 5, account: account, assignee: agent1, status: :open) + end + + it 'excludes agent from all inbox assignments' do + available_agents = inbox.available_agents(check_capacity: true) + expect(available_agents.map(&:user)).not_to include(agent1) + expect(available_agents.map(&:user)).to include(agent2) + end + end + + context 'when capacity policy is not applicable (time exclusion)' do + before do + capacity_policy.update!(exclusion_rules: { + 'overall_capacity' => 5, + 'hours' => [Time.current.hour] + }) + # Create 5 conversations for agent1 (would be at limit if policy was active) + create_list(:conversation, 5, account: account, assignee: agent1, status: :open) + end + + it 'includes agent in available agents' do + available_agents = inbox.available_agents(check_capacity: true) + expect(available_agents.map(&:user)).to include(agent1, agent2) + end + end + end + + describe 'capacity-aware assignment' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :open, assignee: nil) } + + context 'when agents have capacity' do + it 'both agents are available for assignment' do + available = inbox.available_agents(check_capacity: true) + expect(available.map(&:user)).to include(agent1, agent2) + end + end + + context 'when one agent is at capacity' do + before do + # Agent1 at inbox capacity + create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent1, status: :open) + end + + it 'only agent with capacity is available' do + available = inbox.available_agents(check_capacity: true) + expect(available.map(&:user)).not_to include(agent1) + expect(available.map(&:user)).to include(agent2) + end + end + + context 'when all agents are at capacity' do + before do + # Both agents at capacity + account_user2.update!(agent_capacity_policy: capacity_policy) + create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent1, status: :open) + create_list(:conversation, 3, account: account, inbox: inbox, assignee: agent2, status: :open) + end + + it 'no agents are available' do + available = inbox.available_agents(check_capacity: true) + expect(available).to be_empty + end + end + end +end diff --git a/spec/enterprise/services/enterprise/assignment_v2/capacity_service_spec.rb b/spec/enterprise/services/enterprise/assignment_v2/capacity_service_spec.rb new file mode 100644 index 000000000..2d6467c89 --- /dev/null +++ b/spec/enterprise/services/enterprise/assignment_v2/capacity_service_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Enterprise::AssignmentV2::CapacityService do + let(:account) { create(:account) } + let(:user) { create(:user, accounts: [account]) } + let(:account_user) { AccountUser.find_by(account: account, user: user) } + let(:inbox) { create(:inbox, account: account) } + let(:service) { described_class.new(account_user) } + + describe '#agent_has_capacity?' do + context 'without capacity policy' do + it 'returns true' do + expect(service.agent_has_capacity?).to be true + expect(service.agent_has_capacity?(inbox)).to be true + end + end + + context 'with capacity policy' do + let(:policy) { Enterprise::AgentCapacityPolicy.create!(account: account, name: 'Test Policy') } + + before do + account_user.update!(agent_capacity_policy: policy) + end + + context 'when policy is not applicable' do + before do + allow(policy).to receive(:applicable_for_time?).and_return(false) + end + + it 'returns true' do + expect(service.agent_has_capacity?).to be true + end + end + + context 'when checking inbox capacity' do + let!(:inbox_limit) do + Enterprise::InboxCapacityLimit.create!( + agent_capacity_policy: policy, + inbox: inbox, + conversation_limit: 5 + ) + end + + it 'returns true when under limit' do + create_list(:conversation, 3, account: account, inbox: inbox, assignee: user, status: :open) + expect(service.agent_has_capacity?(inbox)).to be true + end + + it 'returns false when at limit' do + create_list(:conversation, 5, account: account, inbox: inbox, assignee: user, status: :open) + expect(service.agent_has_capacity?(inbox)).to be false + end + + it 'checks overall capacity too' do + policy.update!(exclusion_rules: { 'overall_capacity' => 10 }) + create_list(:conversation, 9, account: account, assignee: user, status: :open) + + # Under inbox limit but close to overall limit + expect(service.agent_has_capacity?(inbox)).to be true + + # Add one more to hit overall limit + create(:conversation, account: account, assignee: user, status: :open) + expect(service.agent_has_capacity?(inbox)).to be false + end + end + + context 'when checking overall capacity' do + before do + policy.update!(exclusion_rules: { 'overall_capacity' => 10 }) + end + + it 'returns true when under limit' do + create_list(:conversation, 8, account: account, assignee: user, status: :open) + expect(service.agent_has_capacity?).to be true + end + + it 'returns false when at limit' do + create_list(:conversation, 10, account: account, assignee: user, status: :open) + expect(service.agent_has_capacity?).to be false + end + end + end + end + + describe '#agent_capacity_for_inbox' do + context 'without capacity policy' do + it 'returns infinity' do + expect(service.agent_capacity_for_inbox(inbox)).to eq(Float::INFINITY) + end + end + + context 'with capacity policy and inbox limit' do + let(:policy) { Enterprise::AgentCapacityPolicy.create!(account: account, name: 'Test Policy') } + let!(:inbox_limit) do + Enterprise::InboxCapacityLimit.create!( + agent_capacity_policy: policy, + inbox: inbox, + conversation_limit: 5 + ) + end + + before do + account_user.update!(agent_capacity_policy: policy) + end + + it 'returns remaining capacity' do + create_list(:conversation, 2, account: account, inbox: inbox, assignee: user, status: :open) + expect(service.agent_capacity_for_inbox(inbox)).to eq(3) + end + + it 'returns 0 when at capacity' do + create_list(:conversation, 5, account: account, inbox: inbox, assignee: user, status: :open) + expect(service.agent_capacity_for_inbox(inbox)).to eq(0) + end + end + end + + describe '#agent_overall_capacity' do + context 'without capacity policy' do + it 'returns infinity' do + expect(service.agent_overall_capacity).to eq(Float::INFINITY) + end + end + + context 'with capacity policy and overall limit' do + let(:policy) do + Enterprise::AgentCapacityPolicy.create!( + account: account, + name: 'Overall Policy', + exclusion_rules: { 'overall_capacity' => 10 } + ) + end + + before do + account_user.update!(agent_capacity_policy: policy) + end + + it 'returns remaining capacity' do + create_list(:conversation, 6, account: account, assignee: user, status: :open) + expect(service.agent_overall_capacity).to eq(4) + end + + it 'returns 0 when at capacity' do + create_list(:conversation, 10, account: account, assignee: user, status: :open) + expect(service.agent_overall_capacity).to eq(0) + end + end + end + + describe '#current_conversations_count' do + it 'counts open conversations assigned to user' do + create_list(:conversation, 3, account: account, assignee: user, status: :open) + create(:conversation, account: account, assignee: user, status: :resolved) + create(:conversation, account: account, status: :open) + + expect(service.current_conversations_count).to eq(3) + end + + it 'counts inbox-specific conversations when inbox provided' do + create_list(:conversation, 2, account: account, inbox: inbox, assignee: user, status: :open) + create(:conversation, account: account, assignee: user, status: :open) + + expect(service.current_conversations_count(inbox)).to eq(2) + end + end +end diff --git a/spec/factories/agent_capacity_policies.rb b/spec/factories/agent_capacity_policies.rb new file mode 100644 index 000000000..1cda62012 --- /dev/null +++ b/spec/factories/agent_capacity_policies.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :agent_capacity_policy, class: 'Enterprise::AgentCapacityPolicy' do + account + name { Faker::Name.name } + description { Faker::Lorem.sentence } + exclusion_rules { {} } + end +end diff --git a/spec/factories/inbox_capacity_limits.rb b/spec/factories/inbox_capacity_limits.rb new file mode 100644 index 000000000..54e6da33d --- /dev/null +++ b/spec/factories/inbox_capacity_limits.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :inbox_capacity_limit, class: 'Enterprise::InboxCapacityLimit' do + association :agent_capacity_policy, factory: :agent_capacity_policy + inbox + conversation_limit { 10 } + end +end