Merge branch 'develop' into feat/reporting-events-rollup

This commit is contained in:
Shivam Mishra
2026-02-11 22:55:11 +05:30
committed by GitHub
41 changed files with 1688 additions and 526 deletions
@@ -70,6 +70,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@@ -35,7 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
return head :too_many_requests if conversation.blank?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
@@ -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,
});
@@ -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 }}
</h3>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
@@ -19,11 +19,15 @@ defineProps({
},
});
const emit = defineEmits(['delete']);
const emit = defineEmits(['delete', 'navigate']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<div class="flex items-center gap-2 col-span-2">
<button
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
<span
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
{{ item.name }}
</span>
</div>
<Icon
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
<div class="flex items-start gap-2 col-span-1">
<span
@@ -1,5 +1,5 @@
<script setup>
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);
});
</script>
@@ -73,9 +87,9 @@ onMounted(() => {
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<!-- allow 10 mins to 999 days (in minutes) -->
<DurationInput
v-model:model-value="fairDistributionWindow"
v-model:model-value="windowInMinutes"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
@@ -1,4 +1,6 @@
<script setup>
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 = () => {
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
@@ -41,6 +55,7 @@ const handleChange = () => {
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
@@ -49,11 +64,23 @@ const handleChange = () => {
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<p class="text-sm text-n-slate-11">
{{ description }}
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
</div>
</div>
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
@@ -19,13 +18,10 @@ const enabled = ref(true);
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
);
});
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -584,12 +597,16 @@ const menuItems = computed(() => {
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
...(hasAdvancedAssignment.value
? [
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
+2
View File
@@ -2,6 +2,7 @@ export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2',
ADVANCED_ASSIGNMENT: 'advanced_assignment',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
@@ -56,4 +57,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
];
@@ -766,6 +766,53 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
},
"ASSIGNMENT": {
"TITLE": "Conversation Assignment",
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
"DEFAULT_RULES_TITLE": "Default assignment rules",
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
"DEFAULT_RULE_1": "Earliest created conversations first",
"DEFAULT_RULE_2": "Round robin distribution",
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
"USING_POLICY": "Using custom assignment policy for this inbox",
"CUSTOMIZE_POLICY": "Customize with assignment policy",
"DELETE_POLICY": "Delete policy",
"POLICY_LABEL": "Assignment policy",
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
"POLICY_STATUS": {
"ACTIVE": "Active",
"INACTIVE": "Inactive"
},
"PRIORITY": {
"EARLIEST_CREATED": "Earliest created",
"LONGEST_WAITING": "Longest waiting"
},
"METHOD": {
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
"CREATE_NEW_POLICY": "Create new policy",
"NO_POLICIES": "No assignment policies found",
"VIEW_ALL_POLICIES": "View all policies",
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
"LINK_SUCCESS": "Assignment policy linked successfully",
"LINK_ERROR": "Failed to link assignment policy"
},
"ASSIGNMENT_POLICY": {
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
"CANCEL": "Cancel",
"CONFIRM_DELETE": "Delete",
"DELETE_SUCCESS": "Assignment policy removed successfully",
"DELETE_ERROR": "Failed to remove assignment policy"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
@@ -694,7 +694,8 @@
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy"
"ERROR_MESSAGE": "Failed to create assignment policy",
"INBOX_LINKED": "Inbox has been linked to the policy"
}
},
"EDIT": {
@@ -708,6 +709,12 @@
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel"
},
"INBOX_LINK_PROMPT": {
"TITLE": "Link inbox to policy",
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
"LINK_BUTTON": "Link inbox",
"CANCEL_BUTTON": "Skip"
},
"API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy"
@@ -746,7 +753,9 @@
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity."
"DESCRIPTION": "Assign conversations based on available capacity.",
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
"PREMIUM_BADGE": "Premium"
}
},
"ASSIGNMENT_PRIORITY": {
@@ -832,6 +841,20 @@
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy"
}
},
"INBOX_LIMIT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox limit added successfully",
"ERROR_MESSAGE": "Failed to add inbox limit"
},
"UPDATE": {
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
"ERROR_MESSAGE": "Failed to update inbox limit"
},
"DELETE": {
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
"ERROR_MESSAGE": "Failed to delete inbox limit"
}
}
},
"FORM": {
@@ -1,54 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const agentAssignments = computed(() => [
{
key: 'agent_assignment_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-scale',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-inbox',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
},
],
},
{
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-circle-minus',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-users-round',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
},
],
},
]);
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const agentAssignments = computed(() => {
const assignments = [
{
key: 'agent_assignment_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-scale',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-inbox',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
},
],
},
];
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
// advanced_assignment identifies premium users
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
accountId.value,
'assignment_v2'
);
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
if (hasAssignmentV2 && hasAdvancedAssignment) {
assignments.push({
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
description: t(
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
),
features: [
{
icon: 'i-lucide-glass-water',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
},
{
icon: 'i-lucide-circle-minus',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
},
{
icon: 'i-lucide-users-round',
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
},
],
});
}
return assignments;
});
const handleClick = key => {
router.push({ name: key });
@@ -62,7 +62,7 @@ export default {
name: 'agent_capacity_policy_index',
component: AgentCapacityIndex,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'],
},
},
@@ -71,7 +71,7 @@ export default {
name: 'agent_capacity_policy_create',
component: AgentCapacityCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'],
},
},
@@ -80,7 +80,7 @@ export default {
name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
permissions: ['administrator'],
},
},
@@ -2,13 +2,14 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
@@ -16,20 +17,50 @@ const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
]);
const inboxIdFromQuery = computed(() => {
const id = route.query.inboxId;
return id ? Number(id) : null;
});
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{
label: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
),
},
];
}
return [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
];
});
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
if (item.params) {
const accountId = route.params.accountId;
const inboxId = item.params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({
name: item.routeName,
});
}
};
const handleSubmit = async formState => {
@@ -45,6 +76,8 @@ const handleSubmit = async formState => {
params: {
id: policy.id,
},
// Pass inboxId to edit page to show link prompt
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
});
} catch (error) {
useAlert(
@@ -14,6 +14,7 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
@@ -36,13 +37,46 @@ const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
// Inbox linking prompt from create flow
const inboxIdFromQuery = computed(() => {
const id = route.query.inboxId;
return id ? Number(id) : null;
});
const suggestedInbox = computed(() => {
if (!inboxIdFromQuery.value || !inboxes.value) return null;
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
});
const isLinkingInbox = ref(false);
const dismissInboxLinkPrompt = () => {
router.replace({
name: route.name,
params: route.params,
query: {},
});
};
const breadcrumbItems = computed(() => {
if (inboxIdFromQuery.value) {
return [
{
label: t('INBOX_MGMT.SETTINGS'),
routeName: 'settings_inbox_show',
params: { inboxId: inboxIdFromQuery.value },
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
}
return [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
];
});
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
@@ -66,22 +100,48 @@ const inboxList = computed(() =>
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
enabled: selectedPolicy.value?.enabled || false,
enabled: true,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
}));
const handleDeleteInbox = inboxId =>
store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
const handleDeleteInbox = async inboxId => {
try {
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleBreadcrumbClick = ({ routeName, params }) => {
if (params) {
const accountId = route.params.accountId;
const inboxId = params.inboxId;
// Navigate using explicit path to ensure tab parameter is included
router.push(
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
);
} else {
router.push({ name: routeName });
}
};
const handleNavigateToInbox = inbox => {
router.push({
name: 'settings_inbox_show',
params: {
accountId: route.params.accountId,
inboxId: inbox.id,
},
});
};
const setInboxPolicy = async (inboxId, policyId) => {
try {
@@ -122,6 +182,26 @@ const handleAddInbox = async inbox => {
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
};
const handleLinkSuggestedInbox = async () => {
if (!suggestedInbox.value) return;
isLinkingInbox.value = true;
const inbox = {
id: suggestedInbox.value.id,
name: suggestedInbox.value.name,
};
await handleAddInbox(inbox);
// Clear the query param after linking
router.replace({
name: route.name,
params: route.params,
query: {},
});
isLinkingInbox.value = false;
};
const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
@@ -155,6 +235,11 @@ const handleSubmit = async formState => {
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch inboxes if not already loaded (needed for inbox link prompt)
if (!inboxes.value?.length) {
store.dispatch('inboxes/get');
}
// Fetch policy if not available
if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value);
@@ -186,6 +271,7 @@ watch(routeId, fetchPolicyData, { immediate: true });
@submit="handleSubmit"
@add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox"
@navigate-to-inbox="handleNavigateToInbox"
/>
</template>
@@ -193,5 +279,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox"
/>
<InboxLinkDialog
:inbox="suggestedInbox"
:is-linking="isLinkingInbox"
@link="handleLinkSuggestedInbox"
@dismiss="dismissInboxLinkPrompt"
/>
</SettingsLayout>
</template>
@@ -92,43 +92,68 @@ const formData = computed(() => ({
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleDeleteUser = agentId => {
store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
const handleDeleteUser = async agentId => {
try {
await store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
}
};
const handleAddUser = agent => {
store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
const handleAddUser = async agent => {
try {
await store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
}
};
const handleDeleteInboxLimit = limitId => {
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
const handleDeleteInboxLimit = async limitId => {
try {
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
}
};
const handleAddInboxLimit = limit => {
store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
const handleAddInboxLimit = async limit => {
try {
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
}
};
const handleLimitChange = limit => {
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
const handleLimitChange = async limit => {
try {
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
}
};
const handleSubmit = async formState => {
@@ -1,7 +1,8 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConfig } from 'dashboard/composables/useConfig';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
@@ -23,7 +24,6 @@ const props = defineProps({
default: () => ({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -61,18 +61,24 @@ const emit = defineEmits([
'submit',
'addInbox',
'deleteInbox',
'navigateToInbox',
'validationChange',
]);
const { t } = useI18n();
const { isEnterprise } = useConfig();
const route = useRoute();
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({
name: '',
description: '',
enabled: false,
enabled: true,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -83,20 +89,42 @@ const validationState = ref({
isValid: false,
});
const createOption = (type, key, stateKey) => ({
const createOption = (
type,
key,
stateKey,
disabled = false,
disabledMessage = ''
) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key,
disabled,
disabledMessage,
});
const assignmentOrderOptions = computed(() => {
const options = OPTIONS.ORDER.filter(
key => isEnterprise || key !== 'balanced'
);
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
return OPTIONS.ORDER.map(key => {
const isBalanced = key === 'balanced';
const disabled = isBalanced && !hasAdvancedAssignment;
const disabledMessage = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage
);
});
});
const assignmentPriorityOptions = computed(() =>
@@ -131,7 +159,7 @@ const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
enabled: false,
enabled: true,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -162,15 +190,10 @@ defineExpose({
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
v-model:enabled="state.enabled"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
:status-placeholder="
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
"
@validation-change="handleValidationChange"
/>
@@ -193,6 +216,8 @@ defineExpose({
:label="option.label"
:description="option.description"
:is-active="option.isActive"
:disabled="option.disabled"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event"
/>
</div>
@@ -251,6 +276,7 @@ defineExpose({
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
@navigate="$emit('navigateToInbox', $event)"
/>
</div>
</form>
@@ -0,0 +1,116 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
inbox: {
type: Object,
default: null,
},
isLinking: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['link', 'dismiss']);
const { t } = useI18n();
const dialogRef = ref(null);
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
if (!props.inbox) return 'i-lucide-inbox';
return getInboxIconByType(
props.inbox.channelType,
props.inbox.medium,
'line'
);
});
const openDialog = () => {
dialogRef.value?.open();
};
const closeDialog = () => {
dialogRef.value?.close();
};
const handleConfirm = () => {
emit('link');
};
const handleClose = () => {
emit('dismiss');
};
watch(
() => props.inbox,
async newInbox => {
if (newInbox) {
await nextTick();
openDialog();
} else {
closeDialog();
}
},
{ immediate: true }
);
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.TITLE'
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.LINK_BUTTON'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.CANCEL_BUTTON'
)
"
:is-loading="isLinking"
@confirm="handleConfirm"
@close="handleClose"
>
<template #description>
<p class="text-sm text-n-slate-11">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.INBOX_LINK_PROMPT.DESCRIPTION'
)
}}
</p>
</template>
<div
class="flex items-center gap-3 p-3 rounded-xl border border-n-weak bg-n-alpha-1"
>
<div
class="flex-shrink-0 size-10 rounded-lg bg-n-alpha-2 flex items-center justify-center"
>
<i :class="inboxIcon" class="text-lg text-n-slate-11" />
</div>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ inboxName }}
</span>
</div>
</div>
</Dialog>
</template>
@@ -1,122 +1,321 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
import { useI18n } from 'vue-i18n';
export default {
components: {
SettingsSection,
NextButton,
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
setup() {
const { isEnterprise } = useConfig();
});
return { v$: useVuelidate(), isEnterprise };
},
data() {
return {
selectedAgents: [],
isAgentListUpdating: false,
enableAutoAssignment: false,
maxAssignmentLimit: null,
};
},
computed: {
...mapGetters({
agentList: 'agents/getAgents',
}),
maxAssignmentLimitErrors() {
if (this.v$.maxAssignmentLimit.$error) {
return this.$t(
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
);
}
return '';
},
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
this.maxAssignmentLimit =
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
this.fetchAttachedAgents();
},
async fetchAttachedAgents() {
try {
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
this.selectedAgents = inboxMembers;
} catch (error) {
// Handle error
}
},
handleEnableAutoAssignment() {
this.updateInbox();
},
async updateAgents() {
const agentList = this.selectedAgents.map(el => el.id);
this.isAgentListUpdating = true;
try {
await this.$store.dispatch('inboxMembers/create', {
inboxId: this.inbox.id,
agentList,
});
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
this.isAgentListUpdating = false;
},
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
enable_auto_assignment: this.enableAutoAssignment,
auto_assignment_config: {
max_assignment_limit: this.maxAssignmentLimit,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
}
},
},
validations: {
selectedAgents: {
isEmpty() {
return !!this.selectedAgents.length;
},
},
maxAssignmentLimit: {
minValue: minValue(1),
},
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isEnterprise } = useConfig();
const selectedAgents = ref([]);
const isAgentListUpdating = ref(false);
const enableAutoAssignment = ref(false);
const maxAssignmentLimit = ref(null);
const assignmentPolicy = ref(null);
const isLoadingPolicy = ref(false);
const isDeletingPolicy = ref(false);
const showDeleteConfirmModal = ref(false);
const availablePolicies = ref([]);
const isLoadingPolicies = ref(false);
const showPolicyDropdown = ref(false);
const isLinkingPolicy = ref(false);
const agentList = computed(() => store.getters['agents/getAgents']);
const isFeatureEnabled = feature => {
const accountId = Number(route.params.accountId);
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
feature
);
};
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabled('advanced_assignment');
});
const hasAssignmentV2 = computed(() => {
return isFeatureEnabled('assignment_v2');
});
const showAdvancedAssignmentUI = computed(() => {
return hasAdvancedAssignment.value && hasAssignmentV2.value;
});
const assignmentOrderLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const priority = assignmentPolicy.value.conversation_priority;
if (priority === 'earliest_created') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
}
if (priority === 'longest_waiting') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
}
return priority;
});
const assignmentMethodLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const order = assignmentPolicy.value.assignment_order;
if (order === 'round_robin') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
}
if (order === 'balanced') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
}
return order;
});
// Vuelidate validation rules
const rules = {
maxAssignmentLimit: {
minValue: minValue(1),
},
};
const v$ = useVuelidate(rules, { maxAssignmentLimit });
const maxAssignmentLimitErrors = computed(() => {
if (v$.value.maxAssignmentLimit.$error) {
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
}
return '';
});
const fetchAttachedAgents = async () => {
try {
const response = await store.dispatch('inboxMembers/get', {
inboxId: props.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
selectedAgents.value = inboxMembers;
} catch (error) {
// Handle error
}
};
const fetchAssignmentPolicy = async () => {
if (!props.inbox.id) return;
isLoadingPolicy.value = true;
try {
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
assignmentPolicy.value = response.data;
} catch (error) {
// No policy attached, which is fine
assignmentPolicy.value = null;
} finally {
isLoadingPolicy.value = false;
}
};
const fetchAvailablePolicies = async () => {
isLoadingPolicies.value = true;
try {
const response = await assignmentPoliciesAPI.get();
availablePolicies.value = response.data;
} catch (error) {
availablePolicies.value = [];
} finally {
isLoadingPolicies.value = false;
}
};
const linkPolicyToInbox = async policy => {
isLinkingPolicy.value = true;
try {
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
assignmentPolicy.value = policy;
showPolicyDropdown.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
} finally {
isLinkingPolicy.value = false;
}
};
const navigateToAssignmentPolicies = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_index',
params: { accountId },
});
};
const policyMenuItems = computed(() => {
const items = availablePolicies.value.map(policy => ({
action: 'select_policy',
value: policy.id,
label: policy.name,
icon: 'i-lucide-zap',
policy,
}));
items.push({
action: 'view_all',
value: 'view_all',
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
icon: 'i-lucide-arrow-right',
});
return items;
});
const handlePolicyMenuAction = ({ action, policy }) => {
if (action === 'select_policy' && policy) {
linkPolicyToInbox(policy);
} else if (action === 'view_all') {
navigateToAssignmentPolicies();
}
showPolicyDropdown.value = false;
};
const togglePolicyDropdown = () => {
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
fetchAvailablePolicies();
}
showPolicyDropdown.value = !showPolicyDropdown.value;
};
const closePolicyDropdown = () => {
showPolicyDropdown.value = false;
};
const handleToggleAutoAssignment = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
const updateAgents = async () => {
const agentListIds = selectedAgents.value.map(el => el.id);
isAgentListUpdating.value = true;
try {
await store.dispatch('inboxMembers/create', {
inboxId: props.inbox.id,
agentList: agentListIds,
});
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
isAgentListUpdating.value = false;
};
const updateInbox = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
auto_assignment_config: {
max_assignment_limit: maxAssignmentLimit.value,
},
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
const navigateToCreatePolicy = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_create',
params: { accountId },
query: { inboxId: props.inbox.id },
});
};
const navigateToAssignmentPolicyEdit = () => {
if (!assignmentPolicy.value?.id) return;
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_edit',
params: { accountId, id: assignmentPolicy.value.id },
});
};
const navigateToBilling = () => {
const accountId = route.params.accountId;
router.push({
name: 'billing_settings_index',
params: { accountId },
});
};
const confirmDeletePolicy = () => {
showDeleteConfirmModal.value = true;
};
const cancelDeletePolicy = () => {
showDeleteConfirmModal.value = false;
};
const deleteAssignmentPolicy = async () => {
if (isDeletingPolicy.value) return;
isDeletingPolicy.value = true;
try {
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
assignmentPolicy.value = null;
showDeleteConfirmModal.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
} finally {
isDeletingPolicy.value = false;
}
};
const setDefaults = () => {
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
maxAssignmentLimit.value =
props.inbox.auto_assignment_config?.max_assignment_limit || null;
fetchAttachedAgents();
if (showAdvancedAssignmentUI.value) {
fetchAssignmentPolicy();
fetchAvailablePolicies();
}
};
// Watch only inbox.id to avoid unnecessary refetches when other properties change
watch(() => props.inbox.id, setDefaults);
onMounted(() => {
setDefaults();
});
</script>
<template>
@@ -138,7 +337,6 @@ export default {
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@select="v$.selectedAgents.$touch"
/>
<NextButton
@@ -152,44 +350,325 @@ export default {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
<!-- New UI for assignment_v2 -->
<template v-if="hasAssignmentV2">
<div class="flex items-start gap-3">
<Switch
v-model="enableAutoAssignment"
type="checkbox"
@change="handleEnableAutoAssignment"
class="flex-shrink-0 mt-0.5"
@change="handleToggleAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
</label>
<div class="flex-grow">
<label class="text-sm text-n-slate-12 font-medium mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
</div>
</div>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p>
</label>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="enableAutoAssignment" class="mt-6">
<!-- Policy Card - When policy is attached -->
<div
v-if="showAdvancedAssignmentUI && assignmentPolicy"
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<div class="flex items-start justify-between gap-4 mb-4">
<div class="flex flex-col items-start">
<span class="text-base font-medium text-n-slate-12 mb-1">
{{ assignmentPolicy.name }}
</span>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
</p>
</div>
<NextButton
icon="i-lucide-trash-2"
ghost
ruby
sm
@click="confirmDeletePolicy"
/>
</div>
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
@blur="v$.maxAssignmentLimit.$touch"
/>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentOrderLabel }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentMethodLabel }}
</span>
</li>
</ul>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<div class="w-full h-px my-4 bg-n-weak" />
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
icon="i-lucide-arrow-right"
trailing-icon
link
class="mb-2"
@click="navigateToAssignmentPolicyEdit"
/>
</div>
</div>
</div>
<!-- Default Policy - When no custom policy attached but feature enabled -->
<div
v-else-if="
showAdvancedAssignmentUI &&
!assignmentPolicy &&
!isLoadingPolicy
"
class="rounded-xl outline-1 outline-n-weak outline"
>
<!-- Default Policy Header -->
<div class="p-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
</h4>
<p class="text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
}}
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-5 flex items-center gap-3">
<div
v-if="!isLoadingPolicies && availablePolicies.length > 0"
v-on-click-outside="closePolicyDropdown"
class="relative"
>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
@click="togglePolicyDropdown"
>
<i class="i-lucide-link text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
<i
class="i-lucide-chevron-down text-sm transition-transform"
:class="{ 'rotate-180': showPolicyDropdown }"
/>
</button>
<DropdownMenu
v-if="showPolicyDropdown"
class="top-full left-0 mt-2 min-w-72"
:menu-items="policyMenuItems"
:is-searching="isLoadingPolicies"
@action="handlePolicyMenuAction"
/>
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
@click="navigateToCreatePolicy"
>
<i class="i-lucide-plus text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
</button>
</div>
</div>
<!-- Default Rules Info -->
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
<div class="flex items-start gap-3">
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
<div>
<p class="text-sm text-n-slate-11 mb-2">
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
</p>
<ul class="space-y-1">
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
<div
v-else-if="!showAdvancedAssignmentUI"
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
</h4>
<p class="text-sm text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
</p>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
<div class="w-full h-px bg-n-weak my-4" />
<!-- Upgrade prompt when advanced_assignment is not enabled -->
<div v-if="!hasAdvancedAssignment">
<p class="text-sm text-n-slate-11 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
icon="i-lucide-arrow-right"
trailing-icon
link
@click="navigateToBilling"
/>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<!-- Old UI for non-assignment_v2 -->
<template v-else>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
v-model="enableAutoAssignment"
type="checkbox"
@change="handleToggleAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
</label>
</div>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p>
</label>
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
@blur="v$.maxAssignmentLimit.$touch"
/>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
</template>
</SettingsSection>
<woot-modal
v-if="showDeleteConfirmModal"
:show="showDeleteConfirmModal"
:on-close="cancelDeletePolicy"
>
<div class="p-6">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 ml-13">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
</p>
<div class="flex justify-end gap-2">
<NextButton
color="slate"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
@click="cancelDeletePolicy"
/>
<NextButton
color="ruby"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
:is-loading="isDeletingPolicy"
@click="deleteAssignmentPolicy"
/>
</div>
</div>
</woot-modal>
</div>
</template>
+2
View File
@@ -40,6 +40,7 @@ class Account < ApplicationRecord
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
@@ -89,6 +90,7 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -12,6 +12,10 @@ module AccountEmailRateLimitable
Redis::Alfred.get(email_count_cache_key).to_i
end
def email_transcript_enabled?
true
end
def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit
+2
View File
@@ -75,6 +75,8 @@ class ActionService
end
def send_email_transcript(emails)
return unless @account.email_transcript_enabled?
emails = emails[0].gsub(/\s+/, '').split(',')
emails.each do |email|
@@ -3,6 +3,7 @@ class AutoAssignment::AssignmentService
def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0
@@ -32,7 +33,9 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
# Apply conversation priority using assignment policy if available
policy = inbox.assignment_policy
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
scope.reorder(created_at: :asc)
@@ -81,10 +84,6 @@ class AutoAssignment::AssignmentService
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')
+2 -4
View File
@@ -8,8 +8,6 @@ class AutoAssignment::RateLimiter
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
@@ -24,11 +22,11 @@ class AutoAssignment::RateLimiter
private
def enabled?
limit.present? && limit.positive?
config.present? && limit.positive?
end
def limit
config&.fair_distribution_limit&.to_i || Math
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
end
def window
@@ -1,3 +1,5 @@
require 'faraday/multipart'
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
# Media attachments like photos, videos can be clubbed together and sent as a media group
@@ -111,17 +113,33 @@ class Telegram::SendAttachmentsService
def send_file(chat_id, file_path, reply_to_message_id)
File.open(file_path, 'rb') do |file|
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
body: {
chat_id: chat_id,
**business_connection_body,
document: file,
reply_to_message_id: reply_to_message_id
},
multipart: true)
file_name = File.basename(file_path)
mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream'
payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) }
payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
payload.merge!(business_connection_body)
response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload)
parse_faraday_response(response)
end
end
def multipart_post_connection
@multipart_post_connection ||= Faraday.new do |f|
f.request :multipart
f.options.timeout = 300
f.options.open_timeout = 60
end
end
def parse_faraday_response(response)
parsed = JSON.parse(response.body)
OpenStruct.new(success?: response.success?, parsed_response: parsed)
rescue JSON::ParserError
OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase })
end
def handle_response(response)
return true if response.success?
+4
View File
@@ -241,6 +241,10 @@
display_name: Required Conversation Attributes
enabled: false
premium: true
- name: advanced_assignment
display_name: Advanced Assignment
enabled: false
premium: true
- name: reporting_events_rollup
display_name: Reporting Events Rollup
enabled: false
@@ -3,6 +3,12 @@ module Enterprise::Account
# this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
def selected_feature_flags=(features)
super
sync_assignment_features
end
def mark_for_deletion(reason = 'manual_deletion')
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
@@ -31,4 +37,21 @@ module Enterprise::Account
def saml_enabled?
saml_settings&.saml_enabled? || false
end
private
def sync_assignment_features
if feature_enabled?('assignment_v2')
# Enable advanced_assignment for Business/Enterprise plans
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
else
# Disable advanced_assignment when assignment_v2 is disabled
send('feature_advanced_assignment=', false)
end
end
def business_or_enterprise_plan?
plan_name = custom_attributes['plan_name']
%w[Business Enterprise].include?(plan_name)
end
end
@@ -32,6 +32,13 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
save
end
def email_transcript_enabled?
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
return true if default_plan.blank?
plan_name.present? && plan_name != default_plan['name']
end
def email_rate_limit
account_limit || plan_email_limit || global_limit || default_limit
end
@@ -19,7 +19,8 @@ module Enterprise::AutoAssignment::AssignmentService
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
selector = policy&.balanced? ? balanced_selector : round_robin_selector
# Use balanced selector only if advanced_assignment feature is enabled
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
selector.select_agent(agents)
end
@@ -31,7 +32,7 @@ module Enterprise::AutoAssignment::AssignmentService
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.feature_enabled?('advanced_assignment') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
+10 -5
View File
@@ -36,16 +36,21 @@ class Webhooks::Trigger
case @webhook_type
when :agent_bot_webhook
conversation = message.conversation
return unless conversation&.pending?
conversation.open!
create_agent_bot_error_activity(conversation)
update_conversation_status(message)
when :api_inbox_webhook
update_message_status(error)
end
end
def update_conversation_status(message)
conversation = message.conversation
return unless conversation&.pending?
return if conversation&.account&.keep_pending_on_bot_failure
conversation.open!
create_agent_bot_error_activity(conversation)
end
def create_agent_bot_error_activity(conversation)
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
+98 -57
View File
@@ -1,67 +1,108 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>The page you were looking for doesn't exist (404)</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Page not found</title>
<style>
body {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
background: rgba(39, 129, 246, 0.05);
color: rgb(28, 32, 36);
padding: 2rem 1.5rem;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 28rem;
}
.error-number {
font-size: 8rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
background: linear-gradient(180deg, rgb(39, 129, 246) 0%, rgb(155, 195, 252) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
margin-bottom: 0.5rem;
}
.description {
font-size: 0.9375rem;
color: rgb(96, 100, 108);
line-height: 1.6;
text-align: center;
margin-bottom: 2.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgb(39, 129, 246);
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 0.625rem;
text-decoration: none;
transition: background 0.15s ease;
}
.btn:hover { background: rgb(16, 115, 233); }
.btn svg { width: 1.125rem; height: 1.125rem; }
.divider {
width: 3rem;
height: 2px;
background: rgb(224, 225, 230);
border-radius: 1px;
margin: 2.5rem 0;
}
.help {
font-size: 0.8125rem;
color: rgb(139, 141, 152);
text-align: center;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
.error-number { background: linear-gradient(180deg, rgb(126, 182, 255) 0%, rgb(40, 89, 156) 100%); -webkit-background-clip: text; background-clip: text; }
.description { color: rgb(176, 180, 186); }
.divider { background: rgb(46, 49, 53); }
.help { color: rgb(105, 110, 119); }
}
@media (max-width: 480px) {
.error-number { font-size: 5rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<!-- This file lives in public/404.html -->
<div class="dialog">
<div>
<h1>The page you were looking for doesn't exist.</h1>
<p>You may have mistyped the address or the page may have moved.</p>
</div>
<p>If you are the application owner check the logs for more information.</p>
<div class="page">
<div class="error-number">404</div>
<h1>Page not found</h1>
<p class="description">The page you're looking for doesn't exist or has been moved.</p>
<a href="/" class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to home
</a>
<div class="divider"></div>
<p class="help">If you think this is a mistake, please reach out to support.</p>
</div>
</body>
</html>
+103 -57
View File
@@ -1,67 +1,113 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>The change you wanted was rejected (422)</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Request rejected</title>
<style>
body {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
background: rgba(39, 129, 246, 0.05);
color: rgb(28, 32, 36);
padding: 2rem 1.5rem;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 28rem;
}
.error-number {
font-size: 8rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
background: linear-gradient(180deg, rgb(229, 70, 102) 0%, rgb(248, 191, 200) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
margin-bottom: 0.5rem;
}
.description {
font-size: 0.9375rem;
color: rgb(96, 100, 108);
line-height: 1.6;
text-align: center;
margin-bottom: 2.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgb(39, 129, 246);
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 0.625rem;
text-decoration: none;
transition: background 0.15s ease;
}
.btn:hover { background: rgb(16, 115, 233); }
.btn svg { width: 1.125rem; height: 1.125rem; }
.divider {
width: 3rem;
height: 2px;
background: rgb(224, 225, 230);
border-radius: 1px;
margin: 2.5rem 0;
}
.help {
font-size: 0.8125rem;
color: rgb(139, 141, 152);
text-align: center;
line-height: 1.5;
}
.help a {
color: rgb(39, 129, 246);
text-decoration: none;
}
.help a:hover { text-decoration: underline; }
@media (prefers-color-scheme: dark) {
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
.error-number { background: linear-gradient(180deg, rgb(255, 148, 157) 0%, rgb(136, 52, 71) 100%); -webkit-background-clip: text; background-clip: text; }
.description { color: rgb(176, 180, 186); }
.divider { background: rgb(46, 49, 53); }
.help { color: rgb(105, 110, 119); }
}
@media (max-width: 480px) {
.error-number { font-size: 5rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<!-- This file lives in public/422.html -->
<div class="dialog">
<div>
<h1>The change you wanted was rejected.</h1>
<p>Maybe you tried to change something you didn't have access to.</p>
</div>
<p>If you are the application owner check the logs for more information.</p>
<div class="page">
<div class="error-number">422</div>
<h1>Request rejected</h1>
<p class="description">Your request couldn't be processed due to a security verification failure or invalid data.</p>
<a href="/" class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to home
</a>
<div class="divider"></div>
<p class="help">If this keeps happening, try clearing your cookies and refreshing.</p>
</div>
</body>
</html>
+98 -56
View File
@@ -1,66 +1,108 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>We're sorry, but something went wrong (500)</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Something went wrong</title>
<style>
body {
background-color: #EFEFEF;
color: #2E2F30;
text-align: center;
font-family: arial, sans-serif;
margin: 0;
}
div.dialog {
width: 95%;
max-width: 33em;
margin: 4em auto 0;
}
div.dialog > div {
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #BBB;
border-top: #B00100 solid 4px;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
background-color: white;
padding: 7px 12% 0;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
h1 {
font-size: 100%;
color: #730E15;
line-height: 1.5em;
}
div.dialog > p {
margin: 0 0 1em;
padding: 1em;
background-color: #F7F7F7;
border: 1px solid #CCC;
border-right-color: #999;
border-left-color: #999;
border-bottom-color: #999;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top-color: #DADADA;
color: #666;
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
background: rgba(39, 129, 246, 0.05);
color: rgb(28, 32, 36);
padding: 2rem 1.5rem;
-webkit-font-smoothing: antialiased;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 28rem;
}
.error-number {
font-size: 8rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
background: linear-gradient(180deg, rgb(207, 74, 34) 0%, rgb(247, 164, 120) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
text-align: center;
margin-bottom: 0.5rem;
}
.description {
font-size: 0.9375rem;
color: rgb(96, 100, 108);
line-height: 1.6;
text-align: center;
margin-bottom: 2.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: rgb(39, 129, 246);
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.75rem 2rem;
border-radius: 0.625rem;
text-decoration: none;
transition: background 0.15s ease;
}
.btn:hover { background: rgb(16, 115, 233); }
.btn svg { width: 1.125rem; height: 1.125rem; }
.divider {
width: 3rem;
height: 2px;
background: rgb(224, 225, 230);
border-radius: 1px;
margin: 2.5rem 0;
}
.help {
font-size: 0.8125rem;
color: rgb(139, 141, 152);
text-align: center;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
.error-number { background: linear-gradient(180deg, rgb(255, 148, 114) 0%, rgb(148, 52, 27) 100%); -webkit-background-clip: text; background-clip: text; }
.description { color: rgb(176, 180, 186); }
.divider { background: rgb(46, 49, 53); }
.help { color: rgb(105, 110, 119); }
}
@media (max-width: 480px) {
.error-number { font-size: 5rem; }
h1 { font-size: 1.25rem; }
}
</style>
</head>
<body>
<!-- This file lives in public/500.html -->
<div class="dialog">
<div>
<h1>We're sorry, but something went wrong.</h1>
</div>
<p>If you are the application owner check the logs for more information.</p>
<div class="page">
<div class="error-number">500</div>
<h1>Something went wrong</h1>
<p class="description">An unexpected error occurred. Please refresh the page or try again shortly.</p>
<a href="/" class="btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to home
</a>
<div class="divider"></div>
<p class="help">If the problem persists, please try again in a few minutes.</p>
</div>
</body>
</html>
@@ -16,8 +16,9 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
# Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Enable assignment_v2 (base) and advanced_assignment (premium) features
account.enable_features('assignment_v2')
account.save!
# Set agents as online
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
@@ -52,8 +52,9 @@ RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
agent_at_capacity.id.to_s => 'online'
})
# Enable assignment_v2 feature
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Enable assignment_v2 (base) and advanced_assignment (premium) features
account.enable_features('assignment_v2', 'advanced_assignment')
account.save!
# Create existing assignments for agent_at_capacity (at limit)
3.times do
@@ -14,13 +14,13 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
describe '#perform' do
context 'when account has assignment_v2 feature enabled' do
before do
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
account.enable_features('assignment_v2')
account.save!
allow(Account).to receive(:find_in_batches).and_yield([account])
end
context 'when inbox has auto_assignment_v2 enabled' do
context 'when inbox has assignment policy or auto assignment enabled' do
before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation)
allow(account).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
@@ -41,8 +41,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
policy2 = create(:assignment_policy, account: account2)
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
account2.enable_features('assignment_v2')
account2.save!
inbox_relation2 = instance_double(ActiveRecord::Relation)
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
@@ -58,9 +58,10 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
end
context 'when inbox does not have auto_assignment_v2 enabled' do
context 'when inbox does not have assignment policy or auto assignment enabled' do
before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
inbox.update!(enable_auto_assignment: false)
InboxAssignmentPolicy.where(inbox: inbox).destroy_all
end
it 'does not queue assignment job' do
@@ -73,7 +74,6 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
context 'when account does not have assignment_v2 feature enabled' do
before do
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
allow(Account).to receive(:find_in_batches).and_yield([account])
end
@@ -90,11 +90,11 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
# Create multiple accounts
5.times do |_i|
acc = create(:account)
acc.enable_features('assignment_v2')
acc.save!
inb = create(:inbox, account: acc, enable_auto_assignment: true)
policy = create(:assignment_policy, account: acc)
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation)
allow(acc).to receive(:inboxes).and_return(inbox_relation)
+50 -6
View File
@@ -74,10 +74,11 @@ describe Webhooks::Trigger do
context 'when webhook type is agent bot' do
let(:webhook_type) { :agent_bot_webhook }
let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) }
let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) }
it 'reopens conversation and enqueues activity message if pending' do
conversation.update(status: :pending)
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
payload = { event: 'message_created', id: pending_message.id }
expect(RestClient::Request).to receive(:execute)
.with(
@@ -92,11 +93,11 @@ describe Webhooks::Trigger do
perform_enqueued_jobs do
trigger.execute(url, payload, webhook_type)
end
end.not_to(change { message.reload.status })
end.not_to(change { pending_message.reload.status })
expect(conversation.reload.status).to eq('open')
expect(pending_conversation.reload.status).to eq('open')
activity_message = conversation.reload.messages.order(:created_at).last
activity_message = pending_conversation.reload.messages.order(:created_at).last
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to eq(agent_bot_error_content)
end
@@ -118,9 +119,52 @@ describe Webhooks::Trigger do
end.not_to(change { message.reload.status })
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
expect(conversation.reload.status).to eq('open')
end
it 'keeps conversation pending when keep_pending_on_bot_failure setting is enabled' do
account.update(keep_pending_on_bot_failure: true)
payload = { event: 'message_created', id: pending_message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
trigger.execute(url, payload, webhook_type)
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
expect(pending_conversation.reload.status).to eq('pending')
end
it 'reopens conversation when keep_pending_on_bot_failure setting is disabled' do
account.update(keep_pending_on_bot_failure: false)
payload = { event: 'message_created', id: pending_message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
expect do
perform_enqueued_jobs do
trigger.execute(url, payload, webhook_type)
end
end.not_to(change { pending_message.reload.status })
expect(pending_conversation.reload.status).to eq('open')
activity_message = pending_conversation.reload.messages.order(:created_at).last
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to eq(agent_bot_error_content)
end
end
end
@@ -10,8 +10,9 @@ RSpec.describe AutoAssignment::AssignmentService do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do
# Enable assignment_v2 feature for the account
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Enable assignment_v2 feature for the account (basic assignment features)
account.enable_features('assignment_v2')
account.save!
# Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
create(:inbox_member, inbox: inbox, user: agent)
@@ -59,8 +59,9 @@ RSpec.describe AutoAssignment::RateLimiter do
allow(inbox).to receive(:assignment_policy).and_return(nil)
end
it 'does not track the assignment' do
expect(Redis::Alfred).not_to receive(:set)
it 'still tracks the assignment with default window' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
rate_limiter.track_assignment(conversation)
end
end