feat: exclude stale conversations via assignment policy age threshold (#14766)

## 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>
This commit is contained in:
Tanmay Deep Sharma
2026-06-23 12:22:45 +05:30
committed by GitHub
co-authored by Sony Mathew
parent 647cfc2d83
commit df79c0bbde
14 changed files with 177 additions and 20 deletions
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -798,10 +798,14 @@
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Default age threshold for excluding stale unassigned conversations (7 days)
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
@@ -106,6 +106,7 @@ const formData = computed(() => ({
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
}));
const handleDeleteInbox = async inboxId => {
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
@@ -16,6 +18,7 @@ import {
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
@@ -28,6 +31,7 @@ const props = defineProps({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
}),
},
mode: {
@@ -56,7 +60,6 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
@@ -64,6 +67,9 @@ const emit = defineEmits([
'navigateToInbox',
'validationChange',
]);
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
const MIN_EXCLUSION_MINUTES = 60;
const MAX_EXCLUSION_MINUTES = 1438560;
const { t } = useI18n();
const route = useRoute();
@@ -83,12 +89,28 @@ const state = reactive({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
const validationState = ref({
isValid: false,
});
const exclusionUnit = ref(DURATION_UNITS.DAYS);
// DurationInput works in minutes; the policy stores hours, so bridge the two
const excludeOlderThanMinutes = computed({
get() {
return state.excludeOlderThanHours == null
? null
: state.excludeOlderThanHours * 60;
},
set(minutes) {
state.excludeOlderThanHours =
minutes == null ? null : Math.round(minutes / 60);
},
});
const createOption = (
type,
key,
@@ -170,6 +192,7 @@ const resetForm = () => {
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
};
@@ -177,10 +200,17 @@ const handleSubmit = () => {
emit('submit', { ...state });
};
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
const detectExclusionUnit = hours => {
exclusionUnit.value =
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
detectExclusionUnit(newData.excludeOlderThanHours);
},
{ immediate: true, deep: true }
);
@@ -247,6 +277,27 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
</p>
</div>
<div
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<DurationInput
v-model:unit="exclusionUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_EXCLUSION_MINUTES"
:max="MAX_EXCLUSION_MINUTES"
/>
</div>
</div>
</div>
<Button
+2
View File
@@ -7,6 +7,7 @@
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# exclude_older_than_hours :integer default(168)
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
@@ -28,6 +29,7 @@ class AssignmentPolicy < ApplicationRecord
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
validates :exclude_older_than_hours, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
@@ -35,8 +35,11 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply conversation priority using assignment policy if available
# 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
@@ -46,6 +49,16 @@ class AutoAssignment::AssignmentService
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?
@@ -5,6 +5,7 @@ json.assignment_order assignment_policy.assignment_order
json.conversation_priority assignment_policy.conversation_priority
json.fair_distribution_limit assignment_policy.fair_distribution_limit
json.fair_distribution_window assignment_policy.fair_distribution_window
json.exclude_older_than_hours assignment_policy.exclude_older_than_hours
json.enabled assignment_policy.enabled
json.assigned_inbox_count assignment_policy.inboxes.count
json.created_at assignment_policy.created_at.to_i
@@ -0,0 +1,6 @@
class AddExcludeOlderThanHoursToAssignmentPolicies < ActiveRecord::Migration[7.1]
def change
# Default 168 hours (7 days); nil disables the age exclusion for the policy
add_column :assignment_policies, :exclude_older_than_hours, :integer, default: 168
end
end
+1
View File
@@ -205,6 +205,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "exclude_older_than_hours", default: 168
t.index ["account_id", "name"], name: "index_assignment_policies_on_account_id_and_name", unique: true
t.index ["account_id"], name: "index_assignment_policies_on_account_id"
t.index ["enabled"], name: "index_assignment_policies_on_enabled"
@@ -59,7 +59,10 @@ module Enterprise::AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply exclusion rules from capacity policy or assignment policy
# First apply the assignment policy's age exclusion (defaults to 7 days)
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Then apply the capacity policy's exclusion rules (labels and age)
scope = apply_exclusion_rules(scope)
# Apply conversation priority using enum methods if policy exists
@@ -86,13 +89,4 @@ module Enterprise::AutoAssignment::AssignmentService
scope.tagged_with(excluded_labels, exclude: true, on: :labels)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
scope.where('conversations.created_at >= ?', hours.hours.ago)
end
end
@@ -90,8 +90,8 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
end
context 'when excluding conversations by age' do
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) }
before do
capacity_policy.update!(exclusion_rules: {
@@ -124,10 +124,10 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
context 'when combining exclusion rules' do
it 'applies both exclusion rules' do
# Create conversations
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
# Add labels
old_conversation_with_label.update_labels([label1.title])
@@ -182,5 +182,23 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
expect(conversation2.reload.assignee).to be_present
end
end
context 'when excluding by age via the assignment policy' do
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago) }
before do
InboxCapacityLimit.destroy_all
assignment_policy.update!(exclude_older_than_hours: 24)
end
it 'skips conversations older than the policy threshold without a capacity policy' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to be_nil
expect(recent_conversation.reload.assignee).to be_present
end
end
end
end
+13
View File
@@ -28,6 +28,19 @@ RSpec.describe AssignmentPolicy do
end
end
describe 'exclude_older_than_hours validations' do
it 'requires exclude_older_than_hours to be greater than 0' do
policy = build(:assignment_policy, exclude_older_than_hours: 0)
expect(policy).not_to be_valid
expect(policy.errors[:exclude_older_than_hours]).to include('must be greater than 0')
end
it 'allows exclude_older_than_hours to be nil' do
policy = build(:assignment_policy, exclude_older_than_hours: nil)
expect(policy).to be_valid
end
end
describe 'enum values' do
let(:assignment_policy) { create(:assignment_policy) }
@@ -192,6 +192,55 @@ RSpec.describe AutoAssignment::AssignmentService do
end
end
context 'with age-based exclusion' do
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
allow(rate_limiter).to receive(:within_limit?).and_return(true)
allow(rate_limiter).to receive(:track_assignment)
end
it 'skips conversations inactive beyond the policy threshold' do
assignment_policy.update!(exclude_older_than_hours: 24)
old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 25.hours.ago)
recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 1.hour.ago)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to be_nil
expect(recent_conversation.reload.assignee).to eq(agent)
end
it 'assigns reopened conversations created long ago but recently active' do
assignment_policy.update!(exclude_older_than_hours: 24)
reopened_conversation = create(:conversation, inbox: inbox, assignee: nil,
created_at: 30.days.ago, last_activity_at: 1.hour.ago)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(reopened_conversation.reload.assignee).to eq(agent)
end
it 'assigns conversations regardless of age when threshold is nil' do
assignment_policy.update!(exclude_older_than_hours: nil)
old_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 30.days.ago)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to eq(agent)
end
end
context 'with fair distribution' do
before do
create(:inbox_member, inbox: inbox, user: agent2)