From df79c0bbdeee2cb7d6f6d2a6a615087a1494be3b Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:22:45 +0530 Subject: [PATCH] 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? ## 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 --- .../assignment_policies_controller.rb | 3 +- .../dashboard/i18n/locale/en/settings.json | 6 ++- .../settings/assignmentPolicy/constants.js | 3 ++ .../pages/AgentAssignmentEditPage.vue | 1 + .../components/AgentAssignmentPolicyForm.vue | 53 ++++++++++++++++++- app/models/assignment_policy.rb | 2 + .../auto_assignment/assignment_service.rb | 15 +++++- .../_assignment_policy.json.jbuilder | 1 + ...older_than_hours_to_assignment_policies.rb | 6 +++ db/schema.rb | 1 + .../auto_assignment/assignment_service.rb | 14 ++--- .../assignment_service_spec.rb | 30 ++++++++--- spec/models/assignment_policy_spec.rb | 13 +++++ .../assignment_service_spec.rb | 49 +++++++++++++++++ 14 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 db/migrate/20260617000000_add_exclude_older_than_hours_to_assignment_policies.rb diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -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 diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 5e2543698..0a87b4cbe 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js index 350faa60c..028688c75 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js @@ -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], diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue index dfae60350..0550e7364 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue @@ -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 => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue index cbf22b4d2..36c131ee2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue @@ -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" /> + + + + + {{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }} + + + {{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }} + + + + + + = ?', hours.hours.ago) + end + def find_available_agent(conversation = nil) agents = filter_agents_by_team(inbox.available_agents, conversation) return nil if agents.nil? diff --git a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder index cf09a2949..b55c229b1 100644 --- a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder +++ b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder @@ -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 diff --git a/db/migrate/20260617000000_add_exclude_older_than_hours_to_assignment_policies.rb b/db/migrate/20260617000000_add_exclude_older_than_hours_to_assignment_policies.rb new file mode 100644 index 000000000..9a1b23b1e --- /dev/null +++ b/db/migrate/20260617000000_add_exclude_older_than_hours_to_assignment_policies.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index a3513ca83..15ba36cb6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb index 66cdc31e5..36bbb6c90 100644 --- a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb +++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb index ba1a3eec0..f0b18c57a 100644 --- a/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb +++ b/spec/enterprise/services/enterprise/auto_assignment/assignment_service_spec.rb @@ -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 diff --git a/spec/models/assignment_policy_spec.rb b/spec/models/assignment_policy_spec.rb index 1a97bbda0..2eb9ac57b 100644 --- a/spec/models/assignment_policy_spec.rb +++ b/spec/models/assignment_policy_spec.rb @@ -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) } diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb index eb6ebf060..75dfaa532 100644 --- a/spec/services/auto_assignment/assignment_service_spec.rb +++ b/spec/services/auto_assignment/assignment_service_spec.rb @@ -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)
+ {{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }} +