## Linear ticket - https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents ## Description Assignment policies now skip stale unassigned conversations automatically. Each policy carries an age threshold (defaults to 7 days), so auto-assignment only picks up recent backlog instead of draining very old, forgotten conversations. The threshold is configurable per policy and can be cleared to assign conversations regardless of age. Previously this control existed only on Enterprise capacity policies (`exclude_older_than_hours`); it now lives on the assignment policy itself, so every V2 inbox benefits without needing a capacity policy. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Local UI flows ## Screenshots? <img width="645" height="822" alt="image" src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077" /> ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
139 lines
3.9 KiB
Ruby
139 lines
3.9 KiB
Ruby
class AutoAssignment::AssignmentService
|
|
pattr_initialize [:inbox!]
|
|
|
|
def perform_bulk_assignment(limit: 100)
|
|
return 0 unless inbox.auto_assignment_v2_enabled?
|
|
return 0 unless inbox.enable_auto_assignment?
|
|
|
|
conversations = unassigned_conversations(limit).to_a
|
|
return 0 if conversations.empty?
|
|
return 0 if inbox.available_agents.empty?
|
|
|
|
assigned_count = 0
|
|
conversations.each do |conversation|
|
|
assigned_count += 1 if perform_for_conversation(conversation)
|
|
end
|
|
assigned_count
|
|
end
|
|
|
|
private
|
|
|
|
def perform_for_conversation(conversation)
|
|
return false unless assignable?(conversation)
|
|
|
|
agent = find_available_agent(conversation)
|
|
return false unless agent
|
|
|
|
assign_conversation(conversation, agent)
|
|
end
|
|
|
|
def assignable?(conversation)
|
|
conversation.status == 'open' &&
|
|
conversation.assignee_id.nil?
|
|
end
|
|
|
|
def unassigned_conversations(limit)
|
|
scope = inbox.conversations.unassigned.open
|
|
|
|
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
|
|
policy = inbox.assignment_policy
|
|
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
|
|
|
|
# Apply conversation priority using assignment policy if available
|
|
scope = if policy&.longest_waiting?
|
|
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
|
else
|
|
scope.reorder(created_at: :asc)
|
|
end
|
|
|
|
scope.limit(limit)
|
|
end
|
|
|
|
def apply_age_exclusions(scope, hours_threshold)
|
|
return scope if hours_threshold.blank?
|
|
|
|
hours = hours_threshold.to_i
|
|
return scope unless hours.positive?
|
|
|
|
# Use last_activity_at so reopened/active conversations aren't excluded by their original created_at
|
|
scope.where('conversations.last_activity_at >= ?', hours.hours.ago)
|
|
end
|
|
|
|
def find_available_agent(conversation = nil)
|
|
agents = filter_agents_by_team(inbox.available_agents, conversation)
|
|
return nil if agents.nil?
|
|
|
|
agents = filter_agents_by_rate_limit(agents)
|
|
return nil if agents.empty?
|
|
|
|
round_robin_selector.select_agent(agents)
|
|
end
|
|
|
|
def filter_agents_by_team(agents, conversation)
|
|
return agents if conversation&.team_id.blank?
|
|
|
|
team = conversation.team
|
|
return nil if team.blank? || team.allow_auto_assign.blank?
|
|
|
|
team_member_ids = team.members.ids
|
|
agents.where(user_id: team_member_ids)
|
|
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)
|
|
return false unless claim_and_assign(conversation, agent)
|
|
|
|
conversation.reload
|
|
|
|
rate_limiter = build_rate_limiter(agent)
|
|
rate_limiter.track_assignment(conversation)
|
|
|
|
dispatch_assignment_event(conversation, agent)
|
|
true
|
|
end
|
|
|
|
# Atomically claim the row so two bulk runs that overlap (the in-flight gate
|
|
# is best-effort and can lapse on TTL) can't both assign the same conversation.
|
|
def claim_and_assign(conversation, agent)
|
|
Current.executed_by = inbox.assignment_policy || inbox
|
|
|
|
Conversation.transaction do
|
|
locked = inbox.conversations
|
|
.where(id: conversation.id, assignee_id: nil)
|
|
.lock('FOR UPDATE SKIP LOCKED')
|
|
.first
|
|
next false unless locked
|
|
|
|
locked.update!(assignee: agent)
|
|
true
|
|
end
|
|
ensure
|
|
Current.executed_by = nil
|
|
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
|
|
end
|
|
|
|
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
|