From eacfa2e19aba86b7b0be94c0094ef901b9e08fe2 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Thu, 28 Aug 2025 17:03:11 +0700 Subject: [PATCH] feat: add assignment service --- app/jobs/auto_assignment/assignment_job.rb | 28 +++ .../periodic_assignment_job.rb | 15 ++ .../concerns/auto_assignment_handler.rb | 8 +- .../concerns/inbox_agent_availability.rb | 28 +++ app/models/inbox.rb | 5 + .../auto_assignment/assignment_service.rb | 99 ++++++++++ app/services/auto_assignment/rate_limiter.rb | 49 +++++ .../auto_assignment/round_robin_selector.rb | 16 ++ config/locales/en.yml | 5 + config/schedule.yml | 6 + .../models/enterprise/assignment_policy.rb | 20 ++ .../enterprise/inbox_agent_availability.rb | 31 +++ .../auto_assignment/assignment_service.rb | 60 ++++++ .../auto_assignment/balanced_selector.rb | 26 +++ .../auto_assignment/capacity_service.rb | 44 +++++ lib/redis/alfred.rb | 47 ++++- .../auto_assignment/capacity_service_spec.rb | 138 +++++++++++++ .../agent_assignment_service_spec.rb | 2 - .../assignment_service_basic_spec.rb | 81 ++++++++ ...signment_service_fair_distribution_spec.rb | 126 ++++++++++++ .../assignment_service_spec.rb | 186 ++++++++++++++++++ .../inbox_round_robin_service_spec.rb | 4 +- .../auto_assignment/rate_limiter_spec.rb | 139 +++++++++++++ 23 files changed, 1153 insertions(+), 10 deletions(-) create mode 100644 app/jobs/auto_assignment/assignment_job.rb create mode 100644 app/jobs/auto_assignment/periodic_assignment_job.rb create mode 100644 app/models/concerns/inbox_agent_availability.rb create mode 100644 app/services/auto_assignment/assignment_service.rb create mode 100644 app/services/auto_assignment/rate_limiter.rb create mode 100644 app/services/auto_assignment/round_robin_selector.rb create mode 100644 enterprise/app/models/enterprise/assignment_policy.rb create mode 100644 enterprise/app/models/enterprise/inbox_agent_availability.rb create mode 100644 enterprise/app/services/enterprise/auto_assignment/assignment_service.rb create mode 100644 enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb create mode 100644 enterprise/app/services/enterprise/auto_assignment/capacity_service.rb create mode 100644 spec/enterprise/auto_assignment/capacity_service_spec.rb create mode 100644 spec/services/auto_assignment/assignment_service_basic_spec.rb create mode 100644 spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb create mode 100644 spec/services/auto_assignment/assignment_service_spec.rb create mode 100644 spec/services/auto_assignment/rate_limiter_spec.rb diff --git a/app/jobs/auto_assignment/assignment_job.rb b/app/jobs/auto_assignment/assignment_job.rb new file mode 100644 index 000000000..1aecbda64 --- /dev/null +++ b/app/jobs/auto_assignment/assignment_job.rb @@ -0,0 +1,28 @@ +class AutoAssignment::AssignmentJob < ApplicationJob + queue_as :default + + def perform(inbox_id:) + inbox = Inbox.find_by(id: inbox_id) + return unless inbox + + service = AutoAssignment::AssignmentService.new(inbox: inbox) + + assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit) + success_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_success', + assigned_count: assigned_count, + inbox_id: inbox.id) + Rails.logger.info success_message + rescue StandardError => e + error_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_failed', + inbox_id: inbox.id, + error_message: e.message) + Rails.logger.error error_message + raise e if Rails.env.test? + end + + private + + def bulk_assignment_limit + ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i + end +end diff --git a/app/jobs/auto_assignment/periodic_assignment_job.rb b/app/jobs/auto_assignment/periodic_assignment_job.rb new file mode 100644 index 000000000..1cf80e733 --- /dev/null +++ b/app/jobs/auto_assignment/periodic_assignment_job.rb @@ -0,0 +1,15 @@ +class AutoAssignment::PeriodicAssignmentJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + Account.find_each do |account| + next unless account.feature_enabled?('assignment_v2') + + account.inboxes.joins(:assignment_policy).find_each do |inbox| + next unless inbox.assignment_v2_enabled? + + AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id) + end + end + end +end diff --git a/app/models/concerns/auto_assignment_handler.rb b/app/models/concerns/auto_assignment_handler.rb index de5fdae7d..5e7adbc84 100644 --- a/app/models/concerns/auto_assignment_handler.rb +++ b/app/models/concerns/auto_assignment_handler.rb @@ -14,7 +14,13 @@ module AutoAssignmentHandler return unless conversation_status_changed_to_open? return unless should_run_auto_assignment? - ::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform + if inbox.auto_assignment_enabled? + # Use new assignment system + AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id) + else + # Use legacy assignment system + AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform + end end def should_run_auto_assignment? diff --git a/app/models/concerns/inbox_agent_availability.rb b/app/models/concerns/inbox_agent_availability.rb new file mode 100644 index 000000000..289da7db9 --- /dev/null +++ b/app/models/concerns/inbox_agent_availability.rb @@ -0,0 +1,28 @@ +module InboxAgentAvailability + extend ActiveSupport::Concern + + def available_agents + online_agent_ids = fetch_online_agent_ids + return inbox_members.none if online_agent_ids.empty? + + inbox_members + .joins(:user) + .where(users: { id: online_agent_ids }) + .includes(:user) + end + + def member_ids_with_assignment_capacity + member_ids + end + + private + + def fetch_online_agent_ids + OnlineStatusTracker.get_available_users(account_id) + .select { |_key, value| value.eql?('online') } + .keys + .map(&:to_i) + end +end + +InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability') diff --git a/app/models/inbox.rb b/app/models/inbox.rb index b0f13d222..9e25e82f7 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -44,6 +44,7 @@ class Inbox < ApplicationRecord include Avatarable include OutOfOffisable include AccountCacheRevalidator + include InboxAgentAvailability # Not allowing characters: validates :name, presence: true @@ -190,6 +191,10 @@ class Inbox < ApplicationRecord members.ids end + def auto_assignment_enabled? + account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled? + end + private def default_name_for_blank_name diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb new file mode 100644 index 000000000..0da1d6f1c --- /dev/null +++ b/app/services/auto_assignment/assignment_service.rb @@ -0,0 +1,99 @@ +class AutoAssignment::AssignmentService + pattr_initialize [:inbox!] + + def perform_for_conversation(conversation) + return false unless assignable?(conversation) + + agent = find_available_agent + return false unless agent + + assign_conversation(conversation, agent) + end + + def perform_bulk_assignment(limit: 100) + return 0 unless inbox.enable_auto_assignment? + + assigned_count = 0 + + unassigned_conversations(limit).find_each do |conversation| + assigned_count += 1 if perform_for_conversation(conversation) + end + + assigned_count + end + + private + + def assignable?(conversation) + inbox.enable_auto_assignment? && + conversation.status == 'open' && + conversation.assignee_id.nil? + end + + def unassigned_conversations(limit) + scope = inbox.conversations.unassigned.open + + # Apply conversation priority from config + scope = apply_conversation_priority(scope) + scope.limit(limit) + end + + def apply_conversation_priority(scope) + case assignment_config['conversation_priority'] + when 'longest_waiting' + scope.order(last_activity_at: :asc, created_at: :asc) + else + scope.order(created_at: :asc) + end + end + + def find_available_agent + agents = filter_agents_by_rate_limit(inbox.available_agents) + return nil if agents.empty? + + round_robin_selector.select_agent(agents) + end + + def filter_agents_by_rate_limit(agents) + agents.select do |agent_member| + rate_limiter = build_rate_limiter(agent_member.user) + rate_limiter.within_limit? + end + end + + def assign_conversation(conversation, agent) + conversation.update!(assignee: agent) + + rate_limiter = build_rate_limiter(agent) + rate_limiter.track_assignment(conversation) + + dispatch_assignment_event(conversation, agent) + true + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "AutoAssignment failed for conversation #{conversation.id}: #{e.message}" + false + end + + def dispatch_assignment_event(conversation, agent) + Rails.configuration.dispatcher.dispatch( + Events::Types::ASSIGNEE_CHANGED, + Time.zone.now, + conversation: conversation, + user: agent + ) + end + + def build_rate_limiter(agent) + AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent) + end + + def round_robin_selector + @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox) + end + + def assignment_config + @assignment_config ||= inbox.auto_assignment_config || {} + end +end + +AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService') diff --git a/app/services/auto_assignment/rate_limiter.rb b/app/services/auto_assignment/rate_limiter.rb new file mode 100644 index 000000000..fb3bee837 --- /dev/null +++ b/app/services/auto_assignment/rate_limiter.rb @@ -0,0 +1,49 @@ +class AutoAssignment::RateLimiter + pattr_initialize [:inbox!, :agent!] + + def within_limit? + return true unless enabled? + + current_count < limit + end + + def track_assignment(conversation) + return unless enabled? + + assignment_key = build_assignment_key(conversation.id) + Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window) + end + + def current_count + return 0 unless enabled? + + pattern = assignment_key_pattern + Redis::Alfred.keys_count(pattern) + end + + private + + def enabled? + limit.present? && limit.positive? + end + + def limit + config['fair_distribution_limit']&.to_i + end + + def window + config['fair_distribution_window']&.to_i || 3600 + end + + def config + @config ||= inbox.auto_assignment_config || {} + end + + def assignment_key_pattern + "assignment:#{inbox.id}:agent:#{agent.id}:*" + end + + def build_assignment_key(conversation_id) + "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation_id}" + end +end diff --git a/app/services/auto_assignment/round_robin_selector.rb b/app/services/auto_assignment/round_robin_selector.rb new file mode 100644 index 000000000..65e72090d --- /dev/null +++ b/app/services/auto_assignment/round_robin_selector.rb @@ -0,0 +1,16 @@ +class AutoAssignment::RoundRobinSelector + pattr_initialize [:inbox!] + + def select_agent(available_agents) + return nil if available_agents.empty? + + agent_user_ids = available_agents.map(&:user_id).map(&:to_s) + round_robin_service.available_agent(allowed_agent_ids: agent_user_ids) + end + + private + + def round_robin_service + @round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index f424716af..32eb4ab45 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -374,6 +374,11 @@ en: %{format_messages} agent_capacity_policy: inbox_already_assigned: 'Inbox has already been assigned to this policy' + jobs: + auto_assignment: + assignment_job: + bulk_assignment_success: 'Assigned %{assigned_count} conversations for inbox %{inbox_id}' + bulk_assignment_failed: 'Bulk assignment failed for inbox %{inbox_id}: %{error_message}' portals: send_instructions: email_required: 'Email is required' diff --git a/config/schedule.yml b/config/schedule.yml index c45d395bf..682b0fcd8 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -46,3 +46,9 @@ delete_accounts_job: cron: '0 1 * * *' class: 'Internal::DeleteAccountsJob' queue: scheduled_jobs + +# executed every 30 minutes for assignment_v2 +periodic_assignment_job: + cron: '*/30 * * * *' + class: 'AutoAssignment::PeriodicAssignmentJob' + queue: scheduled_jobs diff --git a/enterprise/app/models/enterprise/assignment_policy.rb b/enterprise/app/models/enterprise/assignment_policy.rb new file mode 100644 index 000000000..490efcc39 --- /dev/null +++ b/enterprise/app/models/enterprise/assignment_policy.rb @@ -0,0 +1,20 @@ +module Enterprise::AssignmentPolicy + def assignment_order=(value) + if value.to_s == 'balanced' + write_attribute(:assignment_order, 1) + else + super + end + end + + def assignment_order + value = read_attribute(:assignment_order) + return 'balanced' if value == 1 + + super + end + + def balanced? + self[:assignment_order] == 1 + end +end diff --git a/enterprise/app/models/enterprise/inbox_agent_availability.rb b/enterprise/app/models/enterprise/inbox_agent_availability.rb new file mode 100644 index 000000000..c90a6d6ff --- /dev/null +++ b/enterprise/app/models/enterprise/inbox_agent_availability.rb @@ -0,0 +1,31 @@ +module Enterprise::InboxAgentAvailability + extend ActiveSupport::Concern + + def member_ids_with_assignment_capacity + return member_ids unless capacity_filtering_enabled? + + # Get online agents with capacity + agents = available_agents + agents = filter_by_capacity(agents) if capacity_filtering_enabled? + agents.map(&:user_id) + end + + private + + def filter_by_capacity(inbox_members_scope) + return inbox_members_scope unless capacity_filtering_enabled? + + inbox_members_scope.select do |inbox_member| + capacity_service.agent_has_capacity?(inbox_member.user, self) + end + end + + def capacity_filtering_enabled? + account.feature_enabled?('assignment_v2') && + account.account_users.joins(:agent_capacity_policy).exists? + end + + def capacity_service + @capacity_service ||= Enterprise::AutoAssignment::CapacityService.new + end +end diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb new file mode 100644 index 000000000..850bd0064 --- /dev/null +++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb @@ -0,0 +1,60 @@ +module Enterprise::AutoAssignment::AssignmentService + private + + # Override assignment config to use policy if available + def assignment_config + return super unless policy + + { + 'conversation_priority' => policy.conversation_priority, + 'fair_distribution_limit' => policy.fair_distribution_limit, + 'fair_distribution_window' => policy.fair_distribution_window, + 'balanced' => policy.balanced? + }.compact + end + + # Override to check policy first + def assignment_enabled? + return policy.enabled? if policy + + super + end + + # Extend agent finding to add capacity checks + def find_available_agent + agents = filter_agents_by_rate_limit(inbox.available_agents) + agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled? + return nil if agents.empty? + + selector = policy&.balanced? ? balanced_selector : round_robin_selector + selector.select_agent(agents) + end + + def filter_agents_by_capacity(agents) + return agents unless capacity_filtering_enabled? + + capacity_service = Enterprise::AutoAssignment::CapacityService.new + agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) } + end + + def capacity_filtering_enabled? + account.feature_enabled?('assignment_v2') && + account.account_users.joins(:agent_capacity_policy).exists? + end + + def round_robin_selector + @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox) + end + + def balanced_selector + @balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox) + end + + def policy + @policy ||= inbox.assignment_policy + end + + def account + inbox.account + end +end diff --git a/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb b/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb new file mode 100644 index 000000000..57017b6bb --- /dev/null +++ b/enterprise/app/services/enterprise/auto_assignment/balanced_selector.rb @@ -0,0 +1,26 @@ +class Enterprise::AutoAssignment::BalancedSelector + pattr_initialize [:inbox!] + + def select_agent(available_agents) + return nil if available_agents.empty? + + agent_users = available_agents.map(&:user) + assignment_counts = fetch_assignment_counts(agent_users) + + agent_users.min_by { |user| assignment_counts[user.id] || 0 } + end + + private + + def fetch_assignment_counts(users) + user_ids = users.map(&:id) + + counts = inbox.conversations + .open + .where(assignee_id: user_ids) + .group(:assignee_id) + .count + + Hash.new(0).merge(counts) + end +end diff --git a/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb b/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb new file mode 100644 index 000000000..5baabb8c6 --- /dev/null +++ b/enterprise/app/services/enterprise/auto_assignment/capacity_service.rb @@ -0,0 +1,44 @@ +class Enterprise::AutoAssignment::CapacityService + def agent_has_capacity?(user, inbox) + # Get the account_user for this specific account + account_user = user.account_users.find_by(account: inbox.account) + + # If no account_user or no capacity policy, agent has unlimited capacity + return true unless account_user&.agent_capacity_policy + + policy = account_user.agent_capacity_policy + + # Check if there's a specific limit for this inbox + inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox) + + # If no specific limit for this inbox, agent has unlimited capacity for this inbox + return true unless inbox_limit + + # Count current open conversations for this agent in this inbox + current_count = user.assigned_conversations + .where(inbox: inbox, status: :open) + .count + + # Agent has capacity if current count is below the limit + current_count < inbox_limit.conversation_limit + end + + def agent_capacity_status(user, inbox) + account_user = user.account_users.find_by(account: inbox.account) + return { has_capacity: true, current: 0, limit: nil } unless account_user&.agent_capacity_policy + + policy = account_user.agent_capacity_policy + inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox) + return { has_capacity: true, current: 0, limit: nil } unless inbox_limit + + current_count = user.assigned_conversations + .where(inbox: inbox, status: :open) + .count + + { + has_capacity: current_count < inbox_limit.conversation_limit, + current: current_count, + limit: inbox_limit.conversation_limit + } + end +end diff --git a/lib/redis/alfred.rb b/lib/redis/alfred.rb index b3e156420..e7e04ed60 100644 --- a/lib/redis/alfred.rb +++ b/lib/redis/alfred.rb @@ -35,6 +35,25 @@ module Redis::Alfred $alfred.with { |conn| conn.exists?(key) } end + # set expiry on a key in seconds + def expire(key, seconds) + $alfred.with { |conn| conn.expire(key, seconds) } + end + + # scan keys matching a pattern + def scan_each(match: nil, count: 100, &) + $alfred.with do |conn| + conn.scan_each(match: match, count: count, &) + end + end + + # count keys matching a pattern + def keys_count(pattern) + count = 0 + scan_each(match: pattern) { count += 1 } + count + end + # list operations def llen(key) @@ -81,8 +100,15 @@ module Redis::Alfred # sorted set operations # add score and value for a key - def zadd(key, score, value) - $alfred.with { |conn| conn.zadd(key, score, value) } + # Modern Redis syntax: zadd(key, [[score, member], ...]) + def zadd(key, score, value = nil) + if value.nil? && score.is_a?(Array) + # New syntax: score is actually an array of [score, member] pairs + $alfred.with { |conn| conn.zadd(key, score) } + else + # Support old syntax for backward compatibility + $alfred.with { |conn| conn.zadd(key, [[score, value]]) } + end end # get score of a value for key @@ -90,9 +116,22 @@ module Redis::Alfred $alfred.with { |conn| conn.zscore(key, value) } end + # count members in a sorted set with scores within the given range + def zcount(key, min_score, max_score) + $alfred.with { |conn| conn.zcount(key, min_score, max_score) } + end + + # get the number of members in a sorted set + def zcard(key) + $alfred.with { |conn| conn.zcard(key) } + end + # get values by score - def zrangebyscore(key, range_start, range_end) - $alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) } + def zrangebyscore(key, range_start, range_end, with_scores: false, limit: nil) + options = {} + options[:with_scores] = with_scores if with_scores + options[:limit] = limit if limit + $alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end, **options) } end # remove values by score diff --git a/spec/enterprise/auto_assignment/capacity_service_spec.rb b/spec/enterprise/auto_assignment/capacity_service_spec.rb new file mode 100644 index 000000000..ea77b85f1 --- /dev/null +++ b/spec/enterprise/auto_assignment/capacity_service_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + + # Assignment policy with rate limiting + let(:assignment_policy) do + create(:assignment_policy, + account: account, + enabled: true, + fair_distribution_limit: 5, + fair_distribution_window: 3600) + end + + # Agent capacity policy + let(:agent_capacity_policy) do + create(:agent_capacity_policy, account: account, name: 'Limited Capacity') + end + + # Agents with different capacity settings + let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) } + let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) } + let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) } + + before do + # Create inbox assignment policy + create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) + + # Set inbox capacity limit + create(:inbox_capacity_limit, + agent_capacity_policy: agent_capacity_policy, + inbox: inbox, + conversation_limit: 3) + + # Assign capacity policy to specific agents + agent_with_capacity.account_users.find_by(account: account) + .update!(agent_capacity_policy: agent_capacity_policy) + + agent_at_capacity.account_users.find_by(account: account) + .update!(agent_capacity_policy: agent_capacity_policy) + + # Create inbox members + create(:inbox_member, inbox: inbox, user: agent_with_capacity) + create(:inbox_member, inbox: inbox, user: agent_without_capacity) + create(:inbox_member, inbox: inbox, user: agent_at_capacity) + + # Mock online status + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ + agent_with_capacity.id.to_s => 'online', + agent_without_capacity.id.to_s => 'online', + agent_at_capacity.id.to_s => 'online' + }) + + # Enable assignment_v2 feature + allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true) + + # Create existing assignments for agent_at_capacity (at limit) + 3.times do + create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open) + end + end + + describe 'capacity filtering' do + it 'excludes agents at capacity' do + available = inbox.available_agents(check_capacity: true) + available_users = available.map(&:user) + + expect(available_users).to include(agent_with_capacity) + expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited + expect(available_users).not_to include(agent_at_capacity) # At capacity limit + end + + it 'respects inbox-specific capacity limits' do + capacity_service = described_class.new + + expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true + expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true + expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false + end + end + + describe 'assignment with capacity' do + let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) } + let(:conversation) { create(:conversation, inbox: inbox, assignee: nil, status: :open) } + + it 'assigns to agents with available capacity' do + # Mock the selector to prefer agent_at_capacity (but should skip due to capacity) + selector = instance_double(AutoAssignment::RoundRobinSelector) + allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector) + allow(selector).to receive(:select_agent) do |agents| + agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) } + end + + expect(service.perform_for_conversation(conversation)).to be true + expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity]) + expect(conversation.reload.assignee).not_to eq(agent_at_capacity) + end + + it 'returns false when all agents are at capacity' do + # Fill up remaining agents + 3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) } + + # agent_without_capacity has no limit, so should still be available + conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open) + expect(service.perform_for_conversation(conversation2)).to be true + expect(conversation2.reload.assignee).to eq(agent_without_capacity) + end + end + + describe 'capacity status' do + it 'provides accurate capacity status for agent at capacity' do + capacity_service = described_class.new + status = capacity_service.agent_capacity_status(agent_at_capacity, inbox) + + expect(status[:has_capacity]).to be false + expect(status[:current]).to eq(3) + expect(status[:limit]).to eq(3) + end + + it 'provides accurate capacity status for agent with capacity' do + capacity_service = described_class.new + status = capacity_service.agent_capacity_status(agent_with_capacity, inbox) + + expect(status[:has_capacity]).to be true + expect(status[:current]).to eq(0) + expect(status[:limit]).to eq(3) + end + + it 'provides accurate capacity status for agent without limit' do + capacity_service = described_class.new + status = capacity_service.agent_capacity_status(agent_without_capacity, inbox) + + expect(status[:has_capacity]).to be true + expect(status[:limit]).to be_nil + end + end +end diff --git a/spec/services/auto_assignment/agent_assignment_service_spec.rb b/spec/services/auto_assignment/agent_assignment_service_spec.rb index 16337dc04..a107ce263 100644 --- a/spec/services/auto_assignment/agent_assignment_service_spec.rb +++ b/spec/services/auto_assignment/agent_assignment_service_spec.rb @@ -1,5 +1,3 @@ -require 'rails_helper' - RSpec.describe AutoAssignment::AgentAssignmentService do let!(:account) { create(:account) } let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } diff --git a/spec/services/auto_assignment/assignment_service_basic_spec.rb b/spec/services/auto_assignment/assignment_service_basic_spec.rb new file mode 100644 index 000000000..9106d8381 --- /dev/null +++ b/spec/services/auto_assignment/assignment_service_basic_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe AutoAssignment::AssignmentService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:agent) { create(:user, account: account, role: :agent, availability: :online) } + let(:service) { described_class.new(inbox: inbox) } + + before do + create(:inbox_member, inbox: inbox, user: agent) + allow(OnlineStatusTracker).to receive(:get_available_users) + .and_return({ agent.id.to_s => 'online' }) + end + + def create_test_conversation(attrs = {}) + conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil, status: :open)) + allow(conversation).to receive(:run_auto_assignment).and_return(nil) + conversation.save! + conversation + end + + describe 'basic assignment' do + context 'when auto assignment is enabled' do + before { inbox.update!(enable_auto_assignment: true) } + + it 'assigns an available agent to conversation' do + conversation = create_test_conversation + + result = service.perform_for_conversation(conversation) + + expect(result).to be true + expect(conversation.reload.assignee).to eq(agent) + end + + it 'returns false when no agents are online' do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({}) + conversation = create_test_conversation + + result = service.perform_for_conversation(conversation) + + expect(result).to be false + expect(conversation.reload.assignee).to be_nil + end + end + + context 'when auto assignment is disabled' do + before { inbox.update!(enable_auto_assignment: false) } + + it 'does not assign any agent' do + conversation = create_test_conversation + + result = service.perform_for_conversation(conversation) + + expect(result).to be false + expect(conversation.reload.assignee).to be_nil + end + end + end + + describe 'assignment conditions' do + before { inbox.update!(enable_auto_assignment: true) } + + it 'only assigns to open conversations' do + resolved_conversation = create_test_conversation(status: 'resolved') + + result = service.perform_for_conversation(resolved_conversation) + + expect(result).to be false + end + + it 'does not reassign already assigned conversations' do + other_agent = create(:user, account: account, role: :agent) + assigned_conversation = create_test_conversation(assignee: other_agent) + + result = service.perform_for_conversation(assigned_conversation) + + expect(result).to be false + expect(assigned_conversation.reload.assignee).to eq(other_agent) + end + end +end diff --git a/spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb b/spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb new file mode 100644 index 000000000..a5d2b0018 --- /dev/null +++ b/spec/services/auto_assignment/assignment_service_fair_distribution_spec.rb @@ -0,0 +1,126 @@ +require 'rails_helper' + +RSpec.describe AutoAssignment::AssignmentService do + let(:account) { create(:account) } + let(:inbox) do + create(:inbox, + account: account, + enable_auto_assignment: true, + auto_assignment_config: { + 'fair_distribution_limit' => 2, + 'fair_distribution_window' => 3600 + }) + end + let(:service) { described_class.new(inbox: inbox) } + let(:agent1) { create(:user, account: account, role: :agent, availability: :online) } + let(:agent2) { create(:user, account: account, role: :agent, availability: :online) } + + before do + create(:inbox_member, inbox: inbox, user: agent1) + create(:inbox_member, inbox: inbox, user: agent2) + allow(OnlineStatusTracker).to receive(:get_available_users) + .and_return({ + agent1.id.to_s => 'online', + agent2.id.to_s => 'online' + }) + # Clean up Redis keys for this inbox + clean_redis_keys + end + + after do + clean_redis_keys + end + + def clean_redis_keys + # Clean up assignment keys for both agents + pattern1 = "assignment:#{inbox.id}:agent:#{agent1.id}:*" + pattern2 = "assignment:#{inbox.id}:agent:#{agent2.id}:*" + + Redis::Alfred.scan_each(match: pattern1) { |key| Redis::Alfred.delete(key) } + Redis::Alfred.scan_each(match: pattern2) { |key| Redis::Alfred.delete(key) } + end + + def create_test_conversation + conversation = build(:conversation, inbox: inbox, assignee: nil, status: :open) + allow(conversation).to receive(:run_auto_assignment).and_return(nil) + conversation.save! + conversation + end + + describe 'with fair distribution enabled' do + it 'respects the assignment limit per agent' do + # Each agent can handle 2 conversations + conversations = Array.new(4) { create_test_conversation } + + # First 4 conversations should be assigned (2 per agent) + conversations.each do |conv| + expect(service.perform_for_conversation(conv)).to be true + end + + # Verify distribution + assigned_to_agent1 = conversations.count { |c| c.reload.assignee == agent1 } + assigned_to_agent2 = conversations.count { |c| c.reload.assignee == agent2 } + + expect(assigned_to_agent1).to eq(2) + expect(assigned_to_agent2).to eq(2) + + # Fifth conversation should fail (both agents at limit) + fifth_conversation = create_test_conversation + expect(service.perform_for_conversation(fifth_conversation)).to be false + expect(fifth_conversation.reload.assignee).to be_nil + end + + it 'tracks assignments using individual Redis keys' do + conversation = create_test_conversation + service.perform_for_conversation(conversation) + + # Check that assignment key exists + pattern = "assignment:#{inbox.id}:agent:#{conversation.reload.assignee.id}:*" + count = Redis::Alfred.keys_count(pattern) + expect(count).to eq(1) + end + + it 'allows new assignments after window expires' do + # Assign 2 conversations to agent1 + 2.times do + conversation = create_test_conversation + allow(service).to receive(:round_robin_selector).and_return( + instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1) + ) + service.perform_for_conversation(conversation) + end + + # Agent1 is now at limit + rate_limiter = AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent1) + expect(rate_limiter.within_limit?).to be false + + # Clear Redis keys to simulate time window expiry + clean_redis_keys + + # Agent1 should be available again + expect(rate_limiter.within_limit?).to be true + + # New assignment should work + new_conversation = create_test_conversation + allow(service).to receive(:round_robin_selector).and_return( + instance_double(AutoAssignment::RoundRobinSelector, select_agent: agent1) + ) + expect(service.perform_for_conversation(new_conversation)).to be true + end + end + + describe 'without fair distribution' do + before do + inbox.update!(auto_assignment_config: {}) + end + + it 'assigns without limits' do + # Create more conversations than would be allowed with limits + 5.times do + conversation = create_test_conversation + expect(service.perform_for_conversation(conversation)).to be true + expect(conversation.reload.assignee).not_to be_nil + end + end + end +end diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb new file mode 100644 index 000000000..62dfdb2b9 --- /dev/null +++ b/spec/services/auto_assignment/assignment_service_spec.rb @@ -0,0 +1,186 @@ +require 'rails_helper' + +RSpec.describe AutoAssignment::AssignmentService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) } + let(:service) { described_class.new(inbox: inbox) } + let(:agent) { create(:user, account: account, role: :agent, availability: :online) } + let(:agent2) { create(:user, account: account, role: :agent, availability: :online) } + + before do + create(:inbox_member, inbox: inbox, user: agent) + end + + def create_test_conversation(attrs = {}) + conversation = build(:conversation, attrs.reverse_merge(inbox: inbox, assignee: nil)) + # Skip the after_save callback to test our service directly + allow(conversation).to receive(:run_auto_assignment).and_return(nil) + conversation.save! + conversation + end + + describe '#perform_for_conversation' do + let(:conversation) { create_test_conversation } + + before do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) + end + + context 'when auto assignment is enabled' do + it 'assigns conversation to available agent' do + expect(service.perform_for_conversation(conversation)).to be true + expect(conversation.reload.assignee).to eq(agent) + end + + it 'dispatches assignee changed event' do + # The conversation update triggers its own event through callbacks + allow(Rails.configuration.dispatcher).to receive(:dispatch).and_call_original + + expect(Rails.configuration.dispatcher).to receive(:dispatch).with( + Events::Types::ASSIGNEE_CHANGED, + anything, + hash_including(conversation: conversation, user: agent) + ) + + service.perform_for_conversation(conversation) + end + + it 'returns false when no agents available' do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({}) + expect(service.perform_for_conversation(conversation)).to be false + end + end + + context 'when conversation already assigned' do + let(:conversation) { create_test_conversation(assignee: agent) } + + it 'does not reassign' do + expect(service.perform_for_conversation(conversation)).to be false + end + end + + context 'when conversation is not open' do + let(:conversation) { create_test_conversation(status: 'resolved') } + + it 'does not assign' do + expect(service.perform_for_conversation(conversation)).to be false + end + end + end + + describe '#perform_bulk_assignment' do + before do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) + 3.times { create_test_conversation(status: :open) } + end + + it 'assigns multiple conversations' do + assigned_count = service.perform_bulk_assignment(limit: 2) + expect(assigned_count).to eq(2) + end + + it 'respects the limit parameter' do + assigned_count = service.perform_bulk_assignment(limit: 1) + expect(assigned_count).to eq(1) + expect(inbox.conversations.unassigned.count).to eq(2) + end + + context 'when auto assignment disabled' do + before { inbox.update!(enable_auto_assignment: false) } + + it 'returns 0' do + expect(service.perform_bulk_assignment).to eq(0) + end + end + end + + describe 'with rate limiting' do + let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) } + + before do + create(:inbox_member, inbox: inbox, user: agent2) + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ + agent.id.to_s => 'online', + agent2.id.to_s => 'online' + }) + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'fair_distribution_limit' => 2, + 'fair_distribution_window' => 3600 + }) + end + + it 'filters agents based on rate limits' do + # Agent 1 has reached limit + rate_limiter_agent1 = instance_double(AutoAssignment::RateLimiter, within_limit?: false) + allow(AutoAssignment::RateLimiter).to receive(:new) + .with(inbox: inbox, agent: agent) + .and_return(rate_limiter_agent1) + + # Agent 2 is within limit + rate_limiter_agent2 = instance_double(AutoAssignment::RateLimiter, within_limit?: true, track_assignment: true) + allow(AutoAssignment::RateLimiter).to receive(:new) + .with(inbox: inbox, agent: agent2) + .and_return(rate_limiter_agent2) + + conversation = create_test_conversation + + expect(service.perform_for_conversation(conversation)).to be true + expect(conversation.reload.assignee).to eq(agent2) + end + + it 'tracks assignments in Redis' do + conversation = create_test_conversation + + rate_limiter = instance_double(AutoAssignment::RateLimiter, within_limit?: true) + allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter) + + expect(rate_limiter).to receive(:track_assignment).with(conversation) + + service.perform_for_conversation(conversation) + end + end + + describe 'conversation priority' do + before do + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' }) + end + + context 'with longest_waiting priority' do + let!(:old_conversation) do + create_test_conversation(status: :open, created_at: 2.hours.ago, last_activity_at: 2.hours.ago) + end + let!(:new_conversation) do + create_test_conversation(status: :open, created_at: 1.hour.ago, last_activity_at: 1.hour.ago) + end + + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'conversation_priority' => 'longest_waiting' + }) + end + + it 'assigns oldest conversation first' do + service.perform_bulk_assignment(limit: 1) + + expect(old_conversation.reload.assignee).to eq(agent) + expect(new_conversation.reload.assignee).to be_nil + end + end + + context 'with default priority' do + let!(:first_created) do + create_test_conversation(status: :open, created_at: 2.hours.ago) + end + let!(:second_created) do + create_test_conversation(status: :open, created_at: 1.hour.ago) + end + + it 'assigns by creation time' do + service.perform_bulk_assignment(limit: 1) + + expect(first_created.reload.assignee).to eq(agent) + expect(second_created.reload.assignee).to be_nil + end + end + end +end diff --git a/spec/services/auto_assignment/inbox_round_robin_service_spec.rb b/spec/services/auto_assignment/inbox_round_robin_service_spec.rb index abf6e42cc..721f2bab8 100644 --- a/spec/services/auto_assignment/inbox_round_robin_service_spec.rb +++ b/spec/services/auto_assignment/inbox_round_robin_service_spec.rb @@ -1,6 +1,4 @@ -require 'rails_helper' - -describe AutoAssignment::InboxRoundRobinService do +RSpec.describe AutoAssignment::InboxRoundRobinService do subject(:inbox_round_robin_service) { described_class.new(inbox: inbox) } let!(:account) { create(:account) } diff --git a/spec/services/auto_assignment/rate_limiter_spec.rb b/spec/services/auto_assignment/rate_limiter_spec.rb new file mode 100644 index 000000000..b503f3f38 --- /dev/null +++ b/spec/services/auto_assignment/rate_limiter_spec.rb @@ -0,0 +1,139 @@ +require 'rails_helper' + +RSpec.describe AutoAssignment::RateLimiter do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:conversation) { create(:conversation, inbox: inbox) } + let(:rate_limiter) { described_class.new(inbox: inbox, agent: agent) } + + describe '#within_limit?' do + context 'when rate limiting is not enabled' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({}) + end + + it 'returns true' do + expect(rate_limiter.within_limit?).to be true + end + end + + context 'when rate limiting is enabled' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'fair_distribution_limit' => 5, + 'fair_distribution_window' => 3600 + }) + end + + it 'returns true when under the limit' do + allow(rate_limiter).to receive(:current_count).and_return(3) + expect(rate_limiter.within_limit?).to be true + end + + it 'returns false when at or over the limit' do + allow(rate_limiter).to receive(:current_count).and_return(5) + expect(rate_limiter.within_limit?).to be false + end + end + end + + describe '#track_assignment' do + context 'when rate limiting is not enabled' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({}) + end + + it 'does not track the assignment' do + expect(Redis::Alfred).not_to receive(:set) + rate_limiter.track_assignment(conversation) + end + end + + context 'when rate limiting is enabled' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'fair_distribution_limit' => 5, + 'fair_distribution_window' => 3600 + }) + end + + it 'creates a Redis key with correct expiry' do + expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation.id}" + expect(Redis::Alfred).to receive(:set).with( + expected_key, + conversation.id.to_s, + ex: 3600 + ) + rate_limiter.track_assignment(conversation) + end + end + end + + describe '#current_count' do + context 'when rate limiting is not enabled' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({}) + end + + it 'returns 0' do + expect(rate_limiter.current_count).to eq(0) + end + end + + context 'when rate limiting is enabled' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'fair_distribution_limit' => 5, + 'fair_distribution_window' => 3600 + }) + end + + it 'counts matching Redis keys' do + pattern = "assignment:#{inbox.id}:agent:#{agent.id}:*" + allow(Redis::Alfred).to receive(:keys_count).with(pattern).and_return(3) + + expect(rate_limiter.current_count).to eq(3) + end + end + end + + describe 'configuration' do + context 'with custom window' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'fair_distribution_limit' => 10, + 'fair_distribution_window' => 7200 + }) + end + + it 'uses the custom window value' do + expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation.id}" + expect(Redis::Alfred).to receive(:set).with( + expected_key, + conversation.id.to_s, + ex: 7200 + ) + rate_limiter.track_assignment(conversation) + end + end + + context 'without custom window' do + before do + allow(inbox).to receive(:auto_assignment_config).and_return({ + 'fair_distribution_limit' => 10 + }) + end + + it 'uses the default window value of 3600' do + expected_key = "assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation.id}" + expect(Redis::Alfred).to receive(:set).with( + expected_key, + conversation.id.to_s, + ex: 3600 + ) + rate_limiter.track_assignment(conversation) + end + end + end +end