From 7b512bd00eb6fb9c84e37082e673ae47e4ecf768 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:24:45 +0530 Subject: [PATCH] fix: V2 Assignment service enhancements (#13036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket: https://linear.app/chatwoot/issue/CW-6081/review-feedback ## Description Assignment V2 Service Enhancements - Enable Assignment V2 on plan upgrade - Fix UI issue with fair distribution policy display - Add advanced assignment feature flag and enhance Assignment V2 capabilities ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? This has been tested using the UI. ## 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 --- > [!NOTE] > **Medium Risk** > Changes auto-assignment execution paths, rate limiting defaults, and feature-flag gating (including premium plan behavior), which could affect which conversations get assigned and when. UI rewires inbox settings and policy flows, so regressions are possible around navigation/linking and feature visibility. > > **Overview** > **Adds a new premium `advanced_assignment` feature flag** and uses it to gate capacity/balanced assignment features in the UI (sidebar entry, settings routes, assignment-policy landing cards) and backend (Enterprise balanced selector + capacity filtering). `advanced_assignment` is marked premium, included in Business plan entitlements, and auto-synced in Enterprise accounts when `assignment_v2` is toggled. > > **Improves Assignment V2 policy UX** by adding an inbox-level “Conversation Assignment” section (behind `assignment_v2`) that can link/unlink an assignment policy, navigate to create/edit policy flows with `inboxId` query context, and show an inbox-link prompt after creating a policy. The policy form now defaults to enabled, disables the `balanced` option with a premium badge/message when unavailable, and inbox lists support click-to-navigate. > > **Tightens/adjusts auto-assignment behavior**: bulk assignment now requires `inbox.enable_auto_assignment?`, conversation ordering uses the attached `assignment_policy` priority, and rate limiting uses `assignment_policy` config with an infinite default limit while still tracking assignments. Tests and i18n strings are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Pranav Co-authored-by: iamsivin Co-authored-by: Muhsin Keloth Co-authored-by: Shivam Mishra --- .../AssignmentPolicyCard.story.vue | 3 - .../AssignmentPolicyCard.vue | 17 - .../AssignmentPolicy/components/DataTable.vue | 22 +- .../components/FairDistribution.vue | 22 +- .../AssignmentPolicy/components/RadioCard.vue | 41 +- .../components/story/BaseInfo.story.vue | 4 - .../components-next/sidebar/Sidebar.vue | 29 +- app/javascript/dashboard/featureFlags.js | 2 + .../dashboard/i18n/locale/en/inboxMgmt.json | 47 ++ .../dashboard/i18n/locale/en/settings.json | 27 +- .../settings/assignmentPolicy/Index.vue | 109 ++- .../assignmentPolicy.routes.js | 6 +- .../pages/AgentAssignmentCreatePage.vue | 59 +- .../pages/AgentAssignmentEditPage.vue | 127 ++- .../pages/AgentCapacityEditPage.vue | 83 +- .../components/AgentAssignmentPolicyForm.vue | 58 +- .../pages/components/InboxLinkDialog.vue | 116 +++ .../inbox/settingsPage/CollaboratorsPage.vue | 759 ++++++++++++++---- .../auto_assignment/assignment_service.rb | 9 +- app/services/auto_assignment/rate_limiter.rb | 6 +- config/features.yml | 4 + enterprise/app/models/enterprise/account.rb | 23 + .../auto_assignment/assignment_service.rb | 5 +- .../billing/handle_stripe_event_service.rb | 2 +- .../assignment_service_spec.rb | 5 +- .../auto_assignment/capacity_service_spec.rb | 5 +- .../periodic_assignment_job_spec.rb | 20 +- .../assignment_service_spec.rb | 5 +- .../auto_assignment/rate_limiter_spec.rb | 5 +- 29 files changed, 1284 insertions(+), 336 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue index cd6f1d49b..20ab38d58 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -39,7 +39,6 @@ const policyA = withCount({ description: 'Distributes conversations evenly among available agents', assignmentOrder: 'round_robin', conversationPriority: 'high', - enabled: true, inboxes: [mockInboxes[0], mockInboxes[1]], isFetchingInboxes: false, }); @@ -50,7 +49,6 @@ const policyB = withCount({ description: 'Assigns based on capacity and workload', assignmentOrder: 'capacity_based', conversationPriority: 'medium', - enabled: true, inboxes: [mockInboxes[2], mockInboxes[3]], isFetchingInboxes: false, }); @@ -61,7 +59,6 @@ const emptyPolicy = withCount({ description: 'Policy with no assigned inboxes', assignmentOrder: 'manual', conversationPriority: 'low', - enabled: false, inboxes: [], isFetchingInboxes: false, }); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue index fe9965777..cedfb0009 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -15,7 +15,6 @@ const props = defineProps({ assignmentOrder: { type: String, default: '' }, conversationPriority: { type: String, default: '' }, assignedInboxCount: { type: Number, default: 0 }, - enabled: { type: Boolean, default: false }, inboxes: { type: Array, default: () => [] }, isFetchingInboxes: { type: Boolean, default: false }, }); @@ -65,22 +64,6 @@ const handleFetchInboxes = () => { {{ name }}
-
- - {{ - enabled - ? t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE' - ) - : t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE' - ) - }} - -
-
+
+ +
-import { ref, onMounted } from 'vue'; +import { ref, computed, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'; import Input from 'dashboard/components-next/input/Input.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; @@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', { }, }); +// The model value is in seconds (for the backend/DB) +// DurationInput works in minutes internally +// We need to convert between seconds and minutes const fairDistributionWindow = defineModel('fairDistributionWindow', { type: Number, default: 3600, @@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', { const windowUnit = ref(DURATION_UNITS.MINUTES); +// Convert seconds to minutes for DurationInput +const windowInMinutes = computed({ + get() { + return Math.floor((fairDistributionWindow.value || 0) / 60); + }, + set(minutes) { + fairDistributionWindow.value = minutes * 60; + }, +}); + +// Detect unit based on minutes (converted from seconds) const detectUnit = minutes => { const m = Number(minutes) || 0; if (m === 0) return DURATION_UNITS.MINUTES; @@ -34,7 +48,7 @@ const detectUnit = minutes => { }; onMounted(() => { - windowUnit.value = detectUnit(fairDistributionWindow.value); + windowUnit.value = detectUnit(windowInMinutes.value); }); @@ -73,9 +87,9 @@ onMounted(() => {
- + +import { useI18n } from 'vue-i18n'; + const props = defineProps({ id: { type: String, @@ -16,12 +18,22 @@ const props = defineProps({ type: Boolean, default: false, }, + disabled: { + type: Boolean, + default: false, + }, + disabledMessage: { + type: String, + default: '', + }, }); const emit = defineEmits(['select']); +const { t } = useI18n(); + const handleChange = () => { - if (!props.isActive) { + if (!props.isActive && !props.disabled) { emit('select', props.id); } }; @@ -29,9 +41,11 @@ const handleChange = () => {