Compare commits

..
Author SHA1 Message Date
Pranav 859ab6777c rich seed data 2026-02-08 13:49:24 -08:00
79 changed files with 2779 additions and 2786 deletions
-2
View File
@@ -94,7 +94,6 @@ yarn-debug.log*
.vscode .vscode
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
.codex/
CLAUDE.local.md CLAUDE.local.md
# Histoire deployment # Histoire deployment
@@ -102,4 +101,3 @@ CLAUDE.local.md
.histoire .histoire
.pnpm-store/* .pnpm-store/*
local/ local/
Procfile.worktree
-7
View File
@@ -50,13 +50,6 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors - Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages ## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional) - Prefer Conventional Commits: `type(scope): subject` (scope optional)
-2
View File
@@ -197,8 +197,6 @@ gem 'ai-agents', '>= 0.7.0'
gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema' gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability # OpenTelemetry for LLM observability
gem 'opentelemetry-sdk' gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp' gem 'opentelemetry-exporter-otlp'
+7 -9
View File
@@ -186,7 +186,6 @@ GEM
byebug (11.1.3) byebug (11.1.3)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
@@ -298,7 +297,7 @@ GEM
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.0) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
@@ -309,8 +308,8 @@ GEM
hashie hashie
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
faraday-net_http (3.4.2) faraday-net_http (3.4.0)
net-http (~> 0.5) net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
@@ -465,7 +464,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.18.1) json (2.13.2)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -563,8 +562,8 @@ GEM
mutex_m (0.3.0) mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)
activerecord (>= 5.2) activerecord (>= 5.2)
net-http (0.9.1) net-http (0.6.0)
uri (>= 0.11.1) uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.20) net-imap (0.4.20)
@@ -969,7 +968,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0) uniform_notifier (1.17.0)
uri (1.1.1) uri (1.0.4)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
@@ -1038,7 +1037,6 @@ DEPENDENCIES
bullet bullet
bundle-audit bundle-audit
byebug byebug
cld3 (~> 3.7)
climate_control climate_control
commonmarker commonmarker
csv-safe csv-safe
@@ -70,7 +70,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? 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? return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@@ -35,9 +35,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def transcript def transcript
return head :too_many_requests if conversation.blank? return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
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 send_transcript_email
head :ok head :ok
@@ -39,6 +39,7 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents', description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin', assignmentOrder: 'round_robin',
conversationPriority: 'high', conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]], inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@@ -49,6 +50,7 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload', description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based', assignmentOrder: 'capacity_based',
conversationPriority: 'medium', conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]], inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes', description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual', assignmentOrder: 'manual',
conversationPriority: 'low', conversationPriority: 'low',
enabled: false,
inboxes: [], inboxes: [],
isFetchingInboxes: false, isFetchingInboxes: false,
}); });
@@ -15,6 +15,7 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' }, assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' }, conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 }, assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] }, inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false }, isFetchingInboxes: { type: Boolean, default: false },
}); });
@@ -64,6 +65,22 @@ const handleFetchInboxes = () => {
{{ name }} {{ name }}
</h3> </h3>
<div class="flex items-center gap-2"> <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 <CardPopover
:title=" :title="
t( t(
@@ -19,15 +19,11 @@ defineProps({
}, },
}); });
const emit = defineEmits(['delete', 'navigate']); const emit = defineEmits(['delete']);
const handleDelete = itemId => { const handleDelete = itemId => {
emit('delete', itemId); emit('delete', itemId);
}; };
const handleNavigate = item => {
emit('navigate', item);
};
</script> </script>
<template> <template>
@@ -51,11 +47,7 @@ const handleNavigate = item => {
:key="item.id" :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" 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"
> >
<button <div class="flex items-center gap-2 col-span-2">
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 <Icon
v-if="item.icon" v-if="item.icon"
:icon="item.icon" :icon="item.icon"
@@ -69,16 +61,10 @@ const handleNavigate = item => {
:size="20" :size="20"
rounded-full rounded-full
/> />
<span <span class="text-sm text-n-slate-12 truncate min-w-0">
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 }} {{ item.name }}
</span> </span>
<Icon </div>
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"> <div class="flex items-start gap-2 col-span-1">
<span <span
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
@@ -15,9 +15,6 @@ 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', { const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number, type: Number,
default: 3600, default: 3600,
@@ -28,17 +25,6 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES); 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 detectUnit = minutes => {
const m = Number(minutes) || 0; const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES; if (m === 0) return DURATION_UNITS.MINUTES;
@@ -48,7 +34,7 @@ const detectUnit = minutes => {
}; };
onMounted(() => { onMounted(() => {
windowUnit.value = detectUnit(windowInMinutes.value); windowUnit.value = detectUnit(fairDistributionWindow.value);
}); });
</script> </script>
@@ -87,9 +73,9 @@ onMounted(() => {
<div <div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110" 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 (in minutes) --> <!-- allow 10 mins to 999 days -->
<DurationInput <DurationInput
v-model:model-value="windowInMinutes" v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit" v-model:unit="windowUnit"
:min="10" :min="10"
:max="1438560" :max="1438560"
@@ -1,6 +1,4 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@@ -18,22 +16,12 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
}); });
const emit = defineEmits(['select']); const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => { const handleChange = () => {
if (!props.isActive && !props.disabled) { if (!props.isActive) {
emit('select', props.id); emit('select', props.id);
} }
}; };
@@ -41,11 +29,9 @@ const handleChange = () => {
<template> <template>
<div <div
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="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="[ :class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer', isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]" ]"
@click="handleChange" @click="handleChange"
> >
@@ -55,7 +41,6 @@ const handleChange = () => {
:checked="isActive" :checked="isActive"
:value="id" :value="id"
:name="id" :name="id"
:disabled="disabled"
type="radio" type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0" class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange" @change="handleChange"
@@ -64,23 +49,11 @@ const handleChange = () => {
<!-- Content --> <!-- Content -->
<div class="flex flex-col gap-3 items-start"> <div class="flex flex-col gap-3 items-start">
<div class="flex items-center gap-2"> <h3 class="text-sm font-medium text-n-slate-12">
<h3 class="text-sm font-medium text-n-slate-12"> {{ label }}
{{ label }} </h3>
</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"> <p class="text-sm text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }} {{ description }}
</p> </p>
</div> </div>
</div> </div>
@@ -6,6 +6,7 @@ const policyName = ref('Round Robin Policy');
const description = ref( const description = ref(
'Distributes conversations evenly among available agents' 'Distributes conversations evenly among available agents'
); );
const enabled = ref(true);
</script> </script>
<template> <template>
@@ -18,10 +19,13 @@ const description = ref(
<BaseInfo <BaseInfo
v-model:policy-name="policyName" v-model:policy-name="policyName"
v-model:description="description" v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name" name-label="Policy Name"
name-placeholder="Enter policy name" name-placeholder="Enter policy name"
description-label="Description" description-label="Description"
description-placeholder="Enter policy description" description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/> />
</div> </div>
</Variant> </Variant>
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core'; import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -51,18 +50,6 @@ const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize(); const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768); 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 => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@@ -597,16 +584,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-users', icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'), to: accountScopedRoute('settings_teams_list'),
}, },
...(hasAdvancedAssignment.value {
? [ name: 'Settings Agent Assignment',
{ label: t('SIDEBAR.AGENT_ASSIGNMENT'),
name: 'Settings Agent Assignment', icon: 'i-lucide-user-cog',
label: t('SIDEBAR.AGENT_ASSIGNMENT'), to: accountScopedRoute('assignment_policy_index'),
icon: 'i-lucide-user-cog', },
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{ {
name: 'Settings Inboxes', name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'), label: t('SIDEBAR.INBOXES'),
@@ -31,10 +31,7 @@ const assignedAgent = computed({
}, },
set(agent) { set(agent) {
const agentId = agent ? agent.id : null; const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', { store.dispatch('setCurrentChatAssignee', agent);
conversationId: currentChat.value?.id,
assignee: agent,
});
store.dispatch('assignAgent', { store.dispatch('assignAgent', {
conversationId: currentChat.value?.id, conversationId: currentChat.value?.id,
agentId, agentId,
-2
View File
@@ -2,7 +2,6 @@ export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots', AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management', AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2', ASSIGNMENT_V2: 'assignment_v2',
ADVANCED_ASSIGNMENT: 'advanced_assignment',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations', AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations', AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns', CAMPAIGNS: 'campaigns',
@@ -57,5 +56,4 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML, FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES, FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
]; ];
@@ -766,53 +766,6 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0", "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" "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": { "FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize", "TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services", "SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
@@ -694,8 +694,7 @@
"CREATE_BUTTON": "Create policy", "CREATE_BUTTON": "Create policy",
"API": { "API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully", "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": { "EDIT": {
@@ -709,12 +708,6 @@
"CONFIRM_BUTTON_LABEL": "Continue", "CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel" "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": { "API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully", "SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy" "ERROR_MESSAGE": "Failed to update assignment policy"
@@ -753,9 +746,7 @@
}, },
"BALANCED": { "BALANCED": {
"LABEL": "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": { "ASSIGNMENT_PRIORITY": {
@@ -841,20 +832,6 @@
"SUCCESS_MESSAGE": "Agent removed from policy successfully", "SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy" "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": { "FORM": {
@@ -85,10 +85,7 @@ export default {
}, },
set(agent) { set(agent) {
const agentId = agent ? agent.id : null; const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', { this.$store.dispatch('setCurrentChatAssignee', agent);
conversationId: this.currentChat.id,
assignee: agent,
});
this.$store this.$store
.dispatch('assignAgent', { .dispatch('assignAgent', {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
@@ -1,81 +1,54 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue'; import SettingsLayout from '../SettingsLayout.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue'; import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const accountId = computed(() => Number(route.params.accountId)); const agentAssignments = computed(() => [
const isFeatureEnabledonAccount = useMapGetter( {
'accounts/isFeatureEnabledonAccount' key: 'agent_assignment_policy_index',
); title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
const agentAssignments = computed(() => { features: [
const assignments = [ {
{ icon: 'i-lucide-circle-fading-arrow-up',
key: 'agent_assignment_policy_index', label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'), },
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'), {
features: [ icon: 'i-lucide-scale',
{ label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
icon: 'i-lucide-circle-fading-arrow-up', },
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'), {
}, icon: 'i-lucide-inbox',
{ label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
icon: 'i-lucide-scale', },
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'), ],
}, },
{ {
icon: 'i-lucide-inbox', key: 'agent_capacity_policy_index',
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'), 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'),
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled },
// advanced_assignment identifies premium users {
const hasAssignmentV2 = isFeatureEnabledonAccount.value( icon: 'i-lucide-circle-minus',
accountId.value, label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
'assignment_v2' },
); {
const hasAdvancedAssignment = isFeatureEnabledonAccount.value( icon: 'i-lucide-users-round',
accountId.value, label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
'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 => { const handleClick = key => {
router.push({ name: key }); router.push({ name: key });
@@ -62,7 +62,7 @@ export default {
name: 'agent_capacity_policy_index', name: 'agent_capacity_policy_index',
component: AgentCapacityIndex, component: AgentCapacityIndex,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT, featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@@ -71,7 +71,7 @@ export default {
name: 'agent_capacity_policy_create', name: 'agent_capacity_policy_create',
component: AgentCapacityCreate, component: AgentCapacityCreate,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT, featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@@ -80,7 +80,7 @@ export default {
name: 'agent_capacity_policy_edit', name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit, component: AgentCapacityEdit,
meta: { meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT, featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'], permissions: ['administrator'],
}, },
}, },
@@ -2,14 +2,13 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue'; import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue'; import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -17,50 +16,20 @@ const { t } = useI18n();
const formRef = ref(null); const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags'); const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxIdFromQuery = computed(() => { const breadcrumbItems = computed(() => [
const id = route.query.inboxId; {
return id ? Number(id) : null; label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
}); routeName: 'agent_assignment_policy_index',
},
const breadcrumbItems = computed(() => { {
if (inboxIdFromQuery.value) { label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
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 => { const handleBreadcrumbClick = item => {
if (item.params) { router.push({
const accountId = route.params.accountId; name: item.routeName,
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 => { const handleSubmit = async formState => {
@@ -76,8 +45,6 @@ const handleSubmit = async formState => {
params: { params: {
id: policy.id, id: policy.id,
}, },
// Pass inboxId to edit page to show link prompt
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
}); });
} catch (error) { } catch (error) {
useAlert( useAlert(
@@ -14,7 +14,6 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue'; import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.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 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'; const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
@@ -37,46 +36,13 @@ const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox // Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null); const inboxLinkedPolicy = ref(null);
// Inbox linking prompt from create flow const breadcrumbItems = computed(() => [
const inboxIdFromQuery = computed(() => { {
const id = route.query.inboxId; label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
return id ? Number(id) : null; routeName: 'agent_assignment_policy_index',
}); },
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
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 => const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({ allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
@@ -100,48 +66,22 @@ const inboxList = computed(() =>
const formData = computed(() => ({ const formData = computed(() => ({
name: selectedPolicy.value?.name || '', name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '', description: selectedPolicy.value?.description || '',
enabled: true, enabled: selectedPolicy.value?.enabled || false,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN, assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority: conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED, selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100, fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600, fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
})); }));
const handleDeleteInbox = async inboxId => { const handleDeleteInbox = inboxId =>
try { store.dispatch('assignmentPolicies/removeInboxPolicy', {
await store.dispatch('assignmentPolicies/removeInboxPolicy', { policyId: selectedPolicy.value?.id,
policyId: selectedPolicy.value?.id, inboxId,
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, 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 handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const setInboxPolicy = async (inboxId, policyId) => { const setInboxPolicy = async (inboxId, policyId) => {
try { try {
@@ -182,26 +122,6 @@ const handleAddInbox = async inbox => {
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id); 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 handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id); const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
@@ -235,11 +155,6 @@ const handleSubmit = async formState => {
const fetchPolicyData = async () => { const fetchPolicyData = async () => {
if (!routeId.value) return; 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 // Fetch policy if not available
if (!selectedPolicy.value?.id) if (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value); await store.dispatch('assignmentPolicies/show', routeId.value);
@@ -271,7 +186,6 @@ watch(routeId, fetchPolicyData, { immediate: true });
@submit="handleSubmit" @submit="handleSubmit"
@add-inbox="handleAddInbox" @add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox" @delete-inbox="handleDeleteInbox"
@navigate-to-inbox="handleNavigateToInbox"
/> />
</template> </template>
@@ -279,12 +193,5 @@ watch(routeId, fetchPolicyData, { immediate: true });
ref="confirmInboxDialogRef" ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox" @add="handleConfirmAddInbox"
/> />
<InboxLinkDialog
:inbox="suggestedInbox"
:is-linking="isLinkingInbox"
@link="handleLinkSuggestedInbox"
@dismiss="dismissInboxLinkPrompt"
/>
</SettingsLayout> </SettingsLayout>
</template> </template>
@@ -92,68 +92,43 @@ const formData = computed(() => ({
const handleBreadcrumbClick = ({ routeName }) => const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName }); router.push({ name: routeName });
const handleDeleteUser = async agentId => { const handleDeleteUser = agentId => {
try { store.dispatch('agentCapacityPolicies/removeUser', {
await store.dispatch('agentCapacityPolicies/removeUser', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, userId: agentId,
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 = async agent => { const handleAddUser = agent => {
try { store.dispatch('agentCapacityPolicies/addUser', {
await store.dispatch('agentCapacityPolicies/addUser', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, userData: { id: agent.id, capacity: 20 },
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 = async limitId => { const handleDeleteInboxLimit = limitId => {
try { store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, limitId,
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 = async limit => { const handleAddInboxLimit = limit => {
try { store.dispatch('agentCapacityPolicies/createInboxLimit', {
await store.dispatch('agentCapacityPolicies/createInboxLimit', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, limitData: {
limitData: { inboxId: limit.inboxId,
inboxId: limit.inboxId, conversationLimit: limit.conversationLimit,
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 = async limit => { const handleLimitChange = limit => {
try { store.dispatch('agentCapacityPolicies/updateInboxLimit', {
await store.dispatch('agentCapacityPolicies/updateInboxLimit', { policyId: selectedPolicyId.value,
policyId: selectedPolicyId.value, limitId: limit.id,
limitId: limit.id, limitData: { conversationLimit: limit.conversationLimit },
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 => { const handleSubmit = async formState => {
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter } from 'dashboard/composables/store';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue'; import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue'; import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue'; import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
@@ -24,6 +23,7 @@ const props = defineProps({
default: () => ({ default: () => ({
name: '', name: '',
description: '', description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -61,24 +61,18 @@ const emit = defineEmits([
'submit', 'submit',
'addInbox', 'addInbox',
'deleteInbox', 'deleteInbox',
'navigateToInbox',
'validationChange', 'validationChange',
]); ]);
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const { isEnterprise } = useConfig();
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY'; const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({ const state = reactive({
name: '', name: '',
description: '', description: '',
enabled: true, enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -89,42 +83,20 @@ const validationState = ref({
isValid: false, isValid: false,
}); });
const createOption = ( const createOption = (type, key, stateKey) => ({
type,
key,
stateKey,
disabled = false,
disabledMessage = ''
) => ({
key, key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`), label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`), description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key, isActive: state[stateKey] === key,
disabled,
disabledMessage,
}); });
const assignmentOrderOptions = computed(() => { const assignmentOrderOptions = computed(() => {
const hasAdvancedAssignment = isFeatureEnabledonAccount.value( const options = OPTIONS.ORDER.filter(
accountId.value, key => isEnterprise || key !== 'balanced'
'advanced_assignment' );
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
); );
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(() => const assignmentPriorityOptions = computed(() =>
@@ -159,7 +131,7 @@ const resetForm = () => {
Object.assign(state, { Object.assign(state, {
name: '', name: '',
description: '', description: '',
enabled: true, enabled: false,
assignmentOrder: ROUND_ROBIN, assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED, conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
@@ -190,10 +162,15 @@ defineExpose({
<BaseInfo <BaseInfo
v-model:policy-name="state.name" v-model:policy-name="state.name"
v-model:description="state.description" v-model:description="state.description"
v-model:enabled="state.enabled"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)" :name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)" :name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)" :description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)" :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" @validation-change="handleValidationChange"
/> />
@@ -216,8 +193,6 @@ defineExpose({
:label="option.label" :label="option.label"
:description="option.description" :description="option.description"
:is-active="option.isActive" :is-active="option.isActive"
:disabled="option.disabled"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event" @select="state[section.key] = $event"
/> />
</div> </div>
@@ -276,7 +251,6 @@ defineExpose({
:is-fetching="isInboxLoading" :is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)" :empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)" @delete="$emit('deleteInbox', $event)"
@navigate="$emit('navigateToInbox', $event)"
/> />
</div> </div>
</form> </form>
@@ -1,116 +0,0 @@
<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,321 +1,122 @@
<script setup> <script>
import { ref, computed, watch, onMounted } from 'vue'; import { mapGetters } from 'vuex';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators'; import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig'; import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue'; import SettingsSection from '../../../../../components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.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';
const props = defineProps({ export default {
inbox: { components: {
type: Object, SettingsSection,
default: () => ({}), NextButton,
}, },
}); props: {
inbox: {
const store = useStore(); type: Object,
const route = useRoute(); default: () => ({}),
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),
}, },
}; setup() {
const { isEnterprise } = useConfig();
const v$ = useVuelidate(rules, { maxAssignmentLimit }); return { v$: useVuelidate(), isEnterprise };
},
const maxAssignmentLimitErrors = computed(() => { data() {
if (v$.value.maxAssignmentLimit.$error) { return {
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'); selectedAgents: [],
} isAgentListUpdating: false,
return ''; enableAutoAssignment: false,
}); maxAssignmentLimit: null,
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')); computed: {
} catch (error) { ...mapGetters({
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); agentList: 'agents/getAgents',
} }),
}; maxAssignmentLimitErrors() {
if (this.v$.maxAssignmentLimit.$error) {
const updateAgents = async () => { return this.$t(
const agentListIds = selectedAgents.value.map(el => el.id); 'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
isAgentListUpdating.value = true; );
try { }
await store.dispatch('inboxMembers/create', { return '';
inboxId: props.inbox.id, },
agentList: agentListIds, },
}); watch: {
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE')); inbox() {
} catch (error) { this.setDefaults();
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE')); },
} },
isAgentListUpdating.value = false; mounted() {
}; this.setDefaults();
},
const updateInbox = async () => { methods: {
try { setDefaults() {
const payload = { this.enableAutoAssignment = this.inbox.enable_auto_assignment;
id: props.inbox.id, this.maxAssignmentLimit =
formData: false, this.inbox?.auto_assignment_config?.max_assignment_limit || null;
enable_auto_assignment: enableAutoAssignment.value, this.fetchAttachedAgents();
auto_assignment_config: { },
max_assignment_limit: maxAssignmentLimit.value, 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;
}, },
}; },
await store.dispatch('inboxes/updateInbox', payload); maxAssignmentLimit: {
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); minValue: minValue(1),
} 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> </script>
<template> <template>
@@ -337,6 +138,7 @@ onMounted(() => {
selected-label selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')" :deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@select="v$.selectedAgents.$touch"
/> />
<NextButton <NextButton
@@ -350,325 +152,44 @@ onMounted(() => {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')" :title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')" :sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
> >
<!-- New UI for assignment_v2 --> <label class="w-3/4 settings-item">
<template v-if="hasAssignmentV2"> <div class="flex items-center gap-2">
<div class="flex items-start gap-3"> <input
<Switch id="enableAutoAssignment"
v-model="enableAutoAssignment" v-model="enableAutoAssignment"
class="flex-shrink-0 mt-0.5" type="checkbox"
@change="handleToggleAutoAssignment" @change="handleEnableAutoAssignment"
/> />
<div class="flex-grow"> <label for="enableAutoAssignment">
<label class="text-sm text-n-slate-12 font-medium mb-1"> {{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }} </label>
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
</div>
</div> </div>
<Transition <p class="pb-1 text-sm not-italic text-n-slate-11">
enter-active-class="transition-all duration-200 ease-out" {{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
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>
<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>
<div class="w-full h-px my-4 bg-n-weak" />
<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> </p>
<div class="flex justify-end gap-2"> </label>
<NextButton
color="slate" <div v-if="enableAutoAssignment && isEnterprise" class="py-3">
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')" <woot-input
@click="cancelDeletePolicy" v-model="maxAssignmentLimit"
/> type="number"
<NextButton :class="{ error: v$.maxAssignmentLimit.$error }"
color="ruby" :error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')" :label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
:is-loading="isDeletingPolicy" @blur="v$.maxAssignmentLimit.$touch"
@click="deleteAssignmentPolicy" />
/>
</div> <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> </div>
</woot-modal> </SettingsSection>
</div> </div>
</template> </template>
@@ -96,7 +96,7 @@ const actions = {
data: payload, data: payload,
}); });
if (!payload.length) { if (!payload.length) {
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId); commit(types.SET_ALL_MESSAGES_LOADED);
} }
} catch (error) { } catch (error) {
// Handle error // Handle error
@@ -191,7 +191,7 @@ const actions = {
async setActiveChat({ commit, dispatch }, { data, after }) { async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data); commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id); commit(types.CLEAR_ALL_MESSAGES_LOADED);
if (data.dataFetched === undefined) { if (data.dataFetched === undefined) {
try { try {
await dispatch('fetchPreviousMessages', { await dispatch('fetchPreviousMessages', {
@@ -199,7 +199,7 @@ const actions = {
before: data.messages[0].id, before: data.messages[0].id,
conversationId: data.id, conversationId: data.id,
}); });
commit(types.SET_CHAT_DATA_FETCHED, data.id); data.dataFetched = true;
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -212,17 +212,14 @@ const actions = {
conversationId, conversationId,
agentId, agentId,
}); });
dispatch('setCurrentChatAssignee', { dispatch('setCurrentChatAssignee', response.data);
conversationId,
assignee: response.data,
});
} catch (error) { } catch (error) {
// Handle error // Handle error
} }
}, },
setCurrentChatAssignee({ commit }, { conversationId, assignee }) { setCurrentChatAssignee({ commit }, assignee) {
commit(types.ASSIGN_AGENT, { conversationId, assignee }); commit(types.ASSIGN_AGENT, assignee);
}, },
assignTeam: async ({ dispatch }, { conversationId, teamId }) => { assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -63,18 +63,14 @@ export const mutations = {
_state.allConversations = []; _state.allConversations = [];
_state.selectedChatId = null; _state.selectedChatId = null;
}, },
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) { [types.SET_ALL_MESSAGES_LOADED](_state) {
const chat = getConversationById(_state)(conversationId); const [chat] = getSelectedChatConversation(_state);
if (chat) { chat.allMessagesLoaded = true;
chat.allMessagesLoaded = true;
}
}, },
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) { [types.CLEAR_ALL_MESSAGES_LOADED](_state) {
const chat = getConversationById(_state)(conversationId); const [chat] = getSelectedChatConversation(_state);
if (chat) { chat.allMessagesLoaded = false;
chat.allMessagesLoaded = false;
}
}, },
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) { [types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChatId = null; _state.selectedChatId = null;
@@ -95,24 +91,15 @@ export const mutations = {
chat.messages = data; chat.messages = data;
}, },
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.dataFetched = true;
}
},
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) { [types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
if (activeChat) { if (activeChat) {
_state.selectedChatId = activeChat.id; _state.selectedChatId = activeChat.id;
} }
}, },
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) { [types.ASSIGN_AGENT](_state, assignee) {
const chat = getConversationById(_state)(conversationId); const [chat] = getSelectedChatConversation(_state);
if (chat) { chat.meta.assignee = assignee;
chat.meta.assignee = assignee;
}
}, },
[types.ASSIGN_TEAM](_state, { team, conversationId }) { [types.ASSIGN_TEAM](_state, { team, conversationId }) {
@@ -287,10 +274,8 @@ export const mutations = {
// Update assignee on action cable message // Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) { [types.UPDATE_ASSIGNEE](_state, payload) {
const chat = getConversationById(_state)(payload.id); const [chat] = _state.allConversations.filter(c => c.id === payload.id);
if (chat) { chat.meta.assignee = payload.assignee;
chat.meta.assignee = payload.assignee;
}
}, },
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) { [types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
@@ -355,26 +355,22 @@ describe('#actions', () => {
axios.post.mockResolvedValue({ axios.post.mockResolvedValue({
data: { id: 1, name: 'User' }, data: { id: 1, name: 'User' },
}); });
await actions.assignAgent( await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
{ dispatch }, expect(commit).toHaveBeenCalledTimes(0);
{ conversationId: 1, agentId: 1 } expect(commit.mock.calls).toEqual([]);
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
});
}); });
}); });
describe('#setCurrentChatAssignee', () => { describe('#setCurrentChatAssignee', () => {
it('sends correct mutations if assignment is successful', async () => { it('sends correct mutations if assignment is successful', async () => {
const payload = { axios.post.mockResolvedValue({
conversationId: 1, data: { id: 1, name: 'User' },
assignee: { id: 1, name: 'User' }, });
}; await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1); expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([['ASSIGN_AGENT', payload]]); expect(commit.mock.calls).toEqual([
['ASSIGN_AGENT', { id: 1, name: 'User' }],
]);
}); });
}); });
@@ -720,64 +716,6 @@ describe('#addMentions', () => {
}); });
}); });
describe('#setActiveChat', () => {
it('should commit SET_CHAT_DATA_FETCHED with conversation ID after fetch', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data, after: 99 }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
[types.SET_CHAT_DATA_FETCHED, 42],
]);
expect(localDispatch).toHaveBeenCalledWith('fetchPreviousMessages', {
after: 99,
before: 100,
conversationId: 42,
});
});
it('should not dispatch fetchPreviousMessages if dataFetched is already set', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn();
const data = { id: 42, messages: [{ id: 100 }], dataFetched: true };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
expect(localCommit.mock.calls).toEqual([
[types.SET_CURRENT_CHAT_WINDOW, data],
[types.CLEAR_ALL_MESSAGES_LOADED, 42],
]);
expect(localDispatch).not.toHaveBeenCalled();
});
it('should commit SET_CHAT_DATA_FETCHED by ID, not mutate the data object directly (race condition fix)', async () => {
const localCommit = vi.fn();
const localDispatch = vi.fn().mockResolvedValue();
const data = { id: 42, messages: [{ id: 100 }] };
await actions.setActiveChat(
{ commit: localCommit, dispatch: localDispatch },
{ data }
);
// The action must NOT set dataFetched on the data object directly
expect(data.dataFetched).toBeUndefined();
// Instead it commits a mutation that finds the conversation by ID in the store
expect(localCommit).toHaveBeenCalledWith(types.SET_CHAT_DATA_FETCHED, 42);
});
});
describe('#getInboxCaptainAssistantById', () => { describe('#getInboxCaptainAssistantById', () => {
it('fetches inbox assistant by id', async () => { it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({ axios.get.mockResolvedValue({
@@ -570,84 +570,25 @@ describe('#mutations', () => {
}); });
}); });
describe('#SET_CHAT_DATA_FETCHED', () => { describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set dataFetched to true on the conversation by ID', () => { it('should set allMessagesLoaded to true on selected chat', () => {
const state = { const state = {
allConversations: [{ id: 1 }, { id: 2 }], allConversations: [{ id: 1, allMessagesLoaded: false }],
};
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[1].dataFetched).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_CHAT_DATA_FETCHED](state, 999);
expect(state.allConversations[0].dataFetched).toBeUndefined();
});
it('should survive the race: SET_ALL_CONVERSATION replaces the object, then SET_CHAT_DATA_FETCHED still works', () => {
// 1. Initial state: conversation exists with dataFetched undefined
const state = {
allConversations: [{ id: 1, messages: [{ id: 'm1' }] }],
selectedChatId: 1, selectedChatId: 1,
}; };
const originalRef = state.allConversations[0]; mutations[types.SET_ALL_MESSAGES_LOADED](state);
// 2. Simulate SET_ALL_CONVERSATION replacing the object (WebSocket/polling)
// This copies dataFetched from the old object (still undefined)
mutations[types.SET_ALL_CONVERSATION](state, [
{ id: 1, name: 'refreshed', messages: [{ id: 'm2' }] },
]);
// The store now holds a NEW object, old reference is detached
const newRef = state.allConversations[0];
expect(newRef).not.toBe(originalRef);
expect(newRef.dataFetched).toBeUndefined();
// 3. SET_CHAT_DATA_FETCHED finds by ID — works on the current store object
mutations[types.SET_CHAT_DATA_FETCHED](state, 1);
expect(state.allConversations[0].dataFetched).toBe(true);
// Old detached reference is unaffected
expect(originalRef.dataFetched).toBeUndefined();
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on the conversation by ID', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }, { id: 2 }],
};
mutations[types.SET_ALL_MESSAGES_LOADED](state, 1);
expect(state.allConversations[0].allMessagesLoaded).toBe(true); expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[1].allMessagesLoaded).toBeUndefined();
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.SET_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBeUndefined();
}); });
}); });
describe('#CLEAR_ALL_MESSAGES_LOADED', () => { describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on the conversation by ID', () => { it('should set allMessagesLoaded to false on selected chat', () => {
const state = { const state = {
allConversations: [ allConversations: [{ id: 1, allMessagesLoaded: true }],
{ id: 1, allMessagesLoaded: true }, selectedChatId: 1,
{ id: 2, allMessagesLoaded: true },
],
}; };
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 1); mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(false); expect(state.allConversations[0].allMessagesLoaded).toBe(false);
expect(state.allConversations[1].allMessagesLoaded).toBe(true);
});
it('should do nothing if conversation is not found', () => {
const state = { allConversations: [{ id: 1, allMessagesLoaded: true }] };
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state, 999);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
}); });
}); });
@@ -699,22 +640,15 @@ describe('#mutations', () => {
}); });
describe('#ASSIGN_AGENT', () => { describe('#ASSIGN_AGENT', () => {
it('should assign agent to the correct conversation by ID', () => { it('should assign agent to selected conversation', () => {
const assignee = { id: 1, name: 'Agent' }; const assignee = { id: 1, name: 'Agent' };
const state = { const state = {
allConversations: [ allConversations: [{ id: 1, meta: {} }],
{ id: 1, meta: {} }, selectedChatId: 1,
{ id: 2, meta: {} },
],
selectedChatId: 2,
}; };
mutations[types.ASSIGN_AGENT](state, { mutations[types.ASSIGN_AGENT](state, assignee);
conversationId: 1,
assignee,
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee); expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[1].meta.assignee).toBeUndefined();
}); });
}); });
@@ -863,34 +797,6 @@ describe('#mutations', () => {
mutations[types.UPDATE_CONVERSATION](state, conversation); mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved'); expect(state.allConversations[0].status).toEqual('resolved');
}); });
it('should preserve dataFetched and allMessagesLoaded during update', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
dataFetched: true,
allMessagesLoaded: true,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
expect(state.allConversations[0].dataFetched).toBe(true);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg1' }]);
});
}); });
describe('#UPDATE_CONVERSATION_CONTACT', () => { describe('#UPDATE_CONVERSATION_CONTACT', () => {
@@ -64,7 +64,6 @@ export default {
SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID', SET_CONTEXT_MENU_CHAT_ID: 'SET_CONTEXT_MENU_CHAT_ID',
SET_CHAT_DATA_FETCHED: 'SET_CHAT_DATA_FETCHED',
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS', SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS', UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
+1 -3
View File
@@ -27,9 +27,7 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
content_type: avatar_file.content_type content_type: avatar_file.content_type
) )
rescue Down::NotFound rescue Down::NotFound, Down::Error => e
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}" Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure ensure
update_avatar_sync_attributes(avatarable, avatar_url) update_avatar_sync_attributes(avatarable, avatar_url)
+13
View File
@@ -2,6 +2,19 @@ class Internal::SeedAccountJob < ApplicationJob
queue_as :low queue_as :low
def perform(account) def perform(account)
# Reload account to ensure we have fresh data
account.reload
# Perform seeding
Seeders::AccountSeeder.new(account: account).perform! Seeders::AccountSeeder.new(account: account).perform!
# Reload again after seeding to ensure cache is fresh
account.reload
Rails.logger.info("Account seeding completed successfully for account_id: #{account.id}")
rescue StandardError => e
Rails.logger.error("Account seeding failed for account_id: #{account.id} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise
end end
end end
+3 -11
View File
@@ -1,7 +1,5 @@
class ReplyMailbox < ApplicationMailbox class ReplyMailbox < ApplicationMailbox
include IncomingEmailValidityHelper attr_accessor :conversation, :processed_mail
attr_accessor :conversation, :processed_mail, :account
before_processing :find_conversation before_processing :find_conversation
@@ -9,17 +7,12 @@ class ReplyMailbox < ApplicationMailbox
# Return early if no conversation was found (e.g., notification emails, suspended accounts) # Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation return unless @conversation
decorate_mail
unless incoming_email_from_valid_email?
Rails.logger.info "Email #{mail.message_id} rejected - failed incoming email validity checks"
return
end
# Wrap everything in a transaction to ensure atomicity # Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails # This prevents orphan conversations if message/attachment creation fails
# and ensures idempotency on job retry (conversation won't be duplicated) # and ensures idempotency on job retry (conversation won't be duplicated)
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
persist_conversation_if_needed persist_conversation_if_needed
decorate_mail
create_message create_message
add_attachments_to_message add_attachments_to_message
end end
@@ -29,7 +22,6 @@ class ReplyMailbox < ApplicationMailbox
def find_conversation def find_conversation
@conversation = Mailbox::ConversationFinder.new(mail).find @conversation = Mailbox::ConversationFinder.new(mail).find
@account = @conversation&.account
# Log when email is rejected # Log when email is rejected
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
end end
@@ -44,6 +36,6 @@ class ReplyMailbox < ApplicationMailbox
end end
def decorate_mail def decorate_mail
@processed_mail = MailPresenter.new(mail, @account) @processed_mail = MailPresenter.new(mail, @conversation.account)
end end
end end
-2
View File
@@ -40,7 +40,6 @@ class Account < ApplicationRecord
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }, 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] }, 'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }, 'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'conversation_required_attributes': { 'conversation_required_attributes': {
'type': %w[array null], 'type': %w[array null],
'items': { 'type': 'string' } 'items': { 'type': 'string' }
@@ -89,7 +88,6 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
has_many :account_users, dependent: :destroy_async has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -12,10 +12,6 @@ module AccountEmailRateLimitable
Redis::Alfred.get(email_count_cache_key).to_i Redis::Alfred.get(email_count_cache_key).to_i
end end
def email_transcript_enabled?
true
end
def within_email_rate_limit? def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit return true if emails_sent_today < email_rate_limit
+8 -35
View File
@@ -130,15 +130,11 @@ class MailPresenter < SimpleDelegator
end end
def sender_name def sender_name
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
end end
def original_sender def original_sender
[ from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
@mail[:reply_to]&.value,
@mail['X-Original-Sender']&.value,
@mail[:from]&.value
].filter_map { |email| parse_mail_address(email)&.address }.first
end end
def headers_data def headers_data
@@ -151,6 +147,10 @@ class MailPresenter < SimpleDelegator
headers.presence headers.presence
end end
def from_email_address(email)
Mail::Address.new(email).address
end
def email_forwarded_for def email_forwarded_for
@mail['X-Forwarded-For'].try(:value) @mail['X-Forwarded-For'].try(:value)
end end
@@ -174,39 +174,12 @@ class MailPresenter < SimpleDelegator
end end
def notification_email_from_chatwoot? def notification_email_from_chatwoot?
sender_address = original_sender.to_s.downcase # notification emails are send via mailer sender email address. so it should match
return false if sender_address.blank? original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
# Notification emails are sent via mailer sender email address.
configured_sender = parse_mail_address(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>'))&.address&.downcase
return true if configured_sender.present? && sender_address.casecmp?(configured_sender)
reply_thread_email_from_chatwoot?(sender_address)
end end
private private
REPLY_THREAD_SENDER_PATTERN = /^reply\+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
def reply_thread_email_from_chatwoot?(sender_address)
inbound_domain = @account&.inbound_email_domain.to_s.downcase
return false if inbound_domain.blank?
local_part, domain = sender_address.split('@', 2)
return false if local_part.blank? || domain.blank?
return false unless domain.casecmp?(inbound_domain)
local_part.match?(REPLY_THREAD_SENDER_PATTERN)
end
def parse_mail_address(email)
return if email.blank?
Mail::Address.new(email)
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
nil
end
def auto_submitted? def auto_submitted?
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no' @mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
end end
-2
View File
@@ -75,8 +75,6 @@ class ActionService
end end
def send_email_transcript(emails) def send_email_transcript(emails)
return unless @account.email_transcript_enabled?
emails = emails[0].gsub(/\s+/, '').split(',') emails = emails[0].gsub(/\s+/, '').split(',')
emails.each do |email| emails.each do |email|
@@ -3,7 +3,6 @@ class AutoAssignment::AssignmentService
def perform_bulk_assignment(limit: 100) def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled? return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0 assigned_count = 0
@@ -33,9 +32,7 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit) def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open scope = inbox.conversations.unassigned.open
# Apply conversation priority using assignment policy if available scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
policy = inbox.assignment_policy
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc) scope.reorder(last_activity_at: :asc, created_at: :asc)
else else
scope.reorder(created_at: :asc) scope.reorder(created_at: :asc)
@@ -84,6 +81,10 @@ class AutoAssignment::AssignmentService
def round_robin_selector def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox) @round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService') AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
+4 -2
View File
@@ -8,6 +8,8 @@ class AutoAssignment::RateLimiter
end end
def track_assignment(conversation) def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id) assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window) Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end end
@@ -22,11 +24,11 @@ class AutoAssignment::RateLimiter
private private
def enabled? def enabled?
config.present? && limit.positive? limit.present? && limit.positive?
end end
def limit def limit
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY config&.fair_distribution_limit&.to_i || Math
end end
def window def window
@@ -2,6 +2,7 @@ class MessageTemplates::HookExecutionService
pattr_initialize [:message!] pattr_initialize [:message!]
def perform def perform
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank? return if conversation.last_incoming_message.blank?
return if message.auto_reply_email? return if message.auto_reply_email?
@@ -20,7 +21,6 @@ class MessageTemplates::HookExecutionService
end end
def should_send_out_of_office_message? def should_send_out_of_office_message?
return false if conversation.campaign.present?
# should not send if its a tweet message # should not send if its a tweet message
return false if conversation.tweet? return false if conversation.tweet?
# should not send for outbound messages # should not send for outbound messages
@@ -37,7 +37,6 @@ class MessageTemplates::HookExecutionService
end end
def should_send_greeting? def should_send_greeting?
return false if conversation.campaign.present?
# should not send if its a tweet message # should not send if its a tweet message
return false if conversation.tweet? return false if conversation.tweet?
@@ -50,8 +49,6 @@ class MessageTemplates::HookExecutionService
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages # TODO: we should be able to reduce this logic once we have a toggle for email collect messages
def should_send_email_collect? def should_send_email_collect?
return false if conversation.campaign.present?
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent? !contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
end end
@@ -20,17 +20,12 @@ class Messages::SendEmailNotificationService
def should_send_email_notification? def should_send_email_notification?
return false unless message.email_notifiable_message? return false unless message.email_notifiable_message?
return false if bot_sender_message?
return false if message.conversation.contact.email.blank? return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit? return false unless message.account.within_email_rate_limit?
email_reply_enabled? email_reply_enabled?
end end
def bot_sender_message?
message.sender_type.in?(%w[AgentBot Captain::Assistant])
end
def email_reply_enabled? def email_reply_enabled?
inbox = message.inbox inbox = message.inbox
case inbox.channel.class.to_s case inbox.channel.class.to_s
@@ -1,5 +1,3 @@
require 'faraday/multipart'
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile # 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 # Media attachments like photos, videos can be clubbed together and sent as a media group
@@ -113,33 +111,17 @@ class Telegram::SendAttachmentsService
def send_file(chat_id, file_path, reply_to_message_id) def send_file(chat_id, file_path, reply_to_message_id)
File.open(file_path, 'rb') do |file| File.open(file_path, 'rb') do |file|
file_name = File.basename(file_path) HTTParty.post("#{channel.telegram_api_url}/sendDocument",
mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream' body: {
chat_id: chat_id,
payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) } **business_connection_body,
payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id document: file,
payload.merge!(business_connection_body) reply_to_message_id: reply_to_message_id
},
response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload) multipart: true)
parse_faraday_response(response)
end end
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) def handle_response(response)
return true if response.success? return true if response.success?
-4
View File
@@ -241,7 +241,3 @@
display_name: Required Conversation Attributes display_name: Required Conversation Attributes
enabled: false enabled: false
premium: true premium: true
- name: advanced_assignment
display_name: Advanced Assignment
enabled: false
premium: true
+698 -45
View File
@@ -18,80 +18,733 @@ unless Rails.env.production?
GlobalConfig.clear_cache GlobalConfig.clear_cache
account = Account.create!( account = Account.create!(
name: 'Acme Inc' name: 'Paperlayer'
) )
secondary_account = Account.create!( secondary_account = Account.create!(
name: 'Acme Org' name: 'Acme Org'
) )
user = User.new(name: 'John', email: 'john@acme.inc', password: 'Password1!', type: 'SuperAdmin') # Create Admin
user = User.new(name: 'Sarah Johnson', email: 'sarah@paperlayer.com', password: 'Password1!', type: 'SuperAdmin')
user.skip_confirmation! user.skip_confirmation!
user.save! user.save!
AccountUser.create!( # Create Sales Team
account_id: account.id, sales_lead = User.new(name: 'Marcus Chen', email: 'marcus@paperlayer.com', password: 'Password1!')
user_id: user.id, sales_lead.skip_confirmation!
role: :administrator sales_lead.save!
sales_agent_1 = User.new(name: 'Emily Rodriguez', email: 'emily@paperlayer.com', password: 'Password1!')
sales_agent_1.skip_confirmation!
sales_agent_1.save!
sales_agent_2 = User.new(name: 'David Kim', email: 'david@paperlayer.com', password: 'Password1!')
sales_agent_2.skip_confirmation!
sales_agent_2.save!
# Create Support Team
support_lead = User.new(name: 'Jennifer Williams', email: 'jennifer@paperlayer.com', password: 'Password1!')
support_lead.skip_confirmation!
support_lead.save!
support_agent_1 = User.new(name: 'Alex Thompson', email: 'alex@paperlayer.com', password: 'Password1!')
support_agent_1.skip_confirmation!
support_agent_1.save!
support_agent_2 = User.new(name: 'Rachel Green', email: 'rachel@paperlayer.com', password: 'Password1!')
support_agent_2.skip_confirmation!
support_agent_2.save!
# Create Operations Team
operations_lead = User.new(name: 'Tom Anderson', email: 'tom@paperlayer.com', password: 'Password1!')
operations_lead.skip_confirmation!
operations_lead.save!
operations_agent = User.new(name: 'Lisa Martinez', email: 'lisa@paperlayer.com', password: 'Password1!')
operations_agent.skip_confirmation!
operations_agent.save!
# Create Account Users
AccountUser.create!(account_id: account.id, user_id: user.id, role: :administrator)
AccountUser.create!(account_id: account.id, user_id: sales_lead.id, role: :administrator)
AccountUser.create!(account_id: account.id, user_id: sales_agent_1.id, role: :agent)
AccountUser.create!(account_id: account.id, user_id: sales_agent_2.id, role: :agent)
AccountUser.create!(account_id: account.id, user_id: support_lead.id, role: :administrator)
AccountUser.create!(account_id: account.id, user_id: support_agent_1.id, role: :agent)
AccountUser.create!(account_id: account.id, user_id: support_agent_2.id, role: :agent)
AccountUser.create!(account_id: account.id, user_id: operations_lead.id, role: :administrator)
AccountUser.create!(account_id: account.id, user_id: operations_agent.id, role: :agent)
AccountUser.create!(account_id: secondary_account.id, user_id: user.id, role: :administrator)
# Create Teams
sales_team = Team.create!(
account: account,
name: 'Sales Team',
description: 'Handles all sales inquiries, quotes, and bulk orders',
allow_auto_assign: true
) )
AccountUser.create!( support_team = Team.create!(
account_id: secondary_account.id, account: account,
user_id: user.id, name: 'Customer Support',
role: :administrator description: 'Handles customer service, product questions, and order issues',
allow_auto_assign: true
) )
web_widget = Channel::WebWidget.create!(account: account, website_url: 'https://acme.inc') operations_team = Team.create!(
account: account,
name: 'Operations',
description: 'Handles shipping, logistics, and order fulfillment',
allow_auto_assign: true
)
inbox = Inbox.create!(channel: web_widget, account: account, name: 'Acme Support') # Add members to Sales Team
TeamMember.create!(team: sales_team, user: sales_lead)
TeamMember.create!(team: sales_team, user: sales_agent_1)
TeamMember.create!(team: sales_team, user: sales_agent_2)
# Add members to Support Team
TeamMember.create!(team: support_team, user: support_lead)
TeamMember.create!(team: support_team, user: support_agent_1)
TeamMember.create!(team: support_team, user: support_agent_2)
# Add members to Operations Team
TeamMember.create!(team: operations_team, user: operations_lead)
TeamMember.create!(team: operations_team, user: operations_agent)
# Create Inboxes
web_widget = Channel::WebWidget.create!(account: account, website_url: 'https://paperlayer.com')
inbox = Inbox.create!(channel: web_widget, account: account, name: 'Website Chat')
InboxMember.create!(user: user, inbox: inbox) InboxMember.create!(user: user, inbox: inbox)
InboxMember.create!(user: sales_lead, inbox: inbox)
InboxMember.create!(user: sales_agent_1, inbox: inbox)
InboxMember.create!(user: sales_agent_2, inbox: inbox)
InboxMember.create!(user: support_lead, inbox: inbox)
InboxMember.create!(user: support_agent_1, inbox: inbox)
InboxMember.create!(user: support_agent_2, inbox: inbox)
InboxMember.create!(user: operations_lead, inbox: inbox)
InboxMember.create!(user: operations_agent, inbox: inbox)
contact_inbox = ContactInboxWithContactBuilder.new( sales_inbox = Inbox.create!(channel: Channel::WebWidget.create!(account: account, website_url: 'https://paperlayer.com/sales'), account: account,
source_id: user.id, name: 'Sales Inquiries')
InboxMember.create!(user: user, inbox: sales_inbox)
InboxMember.create!(user: sales_lead, inbox: sales_inbox)
InboxMember.create!(user: sales_agent_1, inbox: sales_inbox)
InboxMember.create!(user: sales_agent_2, inbox: sales_inbox)
email_channel = Channel::Email.create!(
account: account,
email: 'support@paperlayer.com',
forward_to_email: 'support@paperlayer.com'
)
support_inbox = Inbox.create!(channel: email_channel, account: account, name: 'Email Support')
InboxMember.create!(user: support_lead, inbox: support_inbox)
InboxMember.create!(user: support_agent_1, inbox: support_inbox)
InboxMember.create!(user: support_agent_2, inbox: support_inbox)
# Create Labels
bulk_order_label = Label.create!(account: account, title: 'bulk-order', description: 'Large volume paper orders', color: '#00C875',
show_on_sidebar: true)
custom_print_label = Label.create!(account: account, title: 'custom-printing', description: 'Custom letterhead and printing requests',
color: '#784BD1', show_on_sidebar: true)
urgent_label = Label.create!(account: account, title: 'urgent', description: 'Rush delivery needed', color: '#E2445C', show_on_sidebar: true)
pricing_label = Label.create!(account: account, title: 'pricing-inquiry', description: 'Quote requests and pricing questions', color: '#FDAB3D',
show_on_sidebar: true)
delivery_label = Label.create!(account: account, title: 'delivery-issue', description: 'Shipping and delivery concerns', color: '#9CD326',
show_on_sidebar: true)
product_label = Label.create!(account: account, title: 'product-question', description: 'Questions about paper types and products',
color: '#579BFC', show_on_sidebar: true)
# Create Canned Responses
CannedResponse.create!(account: account, short_code: 'welcome',
content: 'Thank you for contacting Paperlayer! We\'re committed to providing the highest quality paper products for your business. How can I help you today?')
CannedResponse.create!(account: account, short_code: 'bulk',
content: 'For bulk orders over 100 reams, we offer competitive wholesale pricing. Let me get you a custom quote. Could you share your estimated monthly paper needs?')
CannedResponse.create!(account: account, short_code: 'pricing',
content: 'Our standard pricing for premium white copy paper (20lb, 8.5x11) is $45 per case (10 reams). We offer volume discounts starting at 50 cases.')
CannedResponse.create!(account: account, short_code: 'delivery',
content: 'We offer same-day delivery for local orders (within 50 miles) placed before noon, and 2-3 business days for standard shipping. Rush delivery is available for an additional fee.')
CannedResponse.create!(account: account, short_code: 'custom',
content: 'We provide custom letterhead printing services with a minimum order of 5 reams. Typical turnaround is 5-7 business days. Would you like to discuss your design requirements?')
CannedResponse.create!(account: account, short_code: 'stock',
content: 'We carry a full range of paper products: copy paper (20lb-32lb), cardstock, colored paper, glossy photo paper, and specialty papers. What type are you interested in?')
CannedResponse.create!(account: account, short_code: 'closing',
content: 'Thank you for choosing Paperlayer! Is there anything else I can help you with today?')
CannedResponse.create!(account: account, short_code: 'followup',
content: 'I wanted to follow up on your recent inquiry. Have you had a chance to review our quote?')
CannedResponse.create!(account: account, short_code: 'tracking',
content: 'I can help you track your order. Could you please provide your order number?')
CannedResponse.create!(account: account, short_code: 'samples',
content: 'We\'d be happy to send you paper samples! What types of paper are you interested in testing?')
# Create Macros
Macro.create!(
account: account,
name: 'Handle Bulk Order Inquiry',
visibility: :global,
created_by: user,
actions: [
{ 'action_name' => 'add_label', 'action_params' => [bulk_order_label.title] },
{ 'action_name' => 'assign_team', 'action_params' => [sales_team.id] },
{ 'action_name' => 'send_message',
'action_params' => ['For bulk orders over 100 reams, we offer competitive wholesale pricing. Let me get you a custom quote. Could you share your estimated monthly paper needs?'] }
]
)
Macro.create!(
account: account,
name: 'Handle Urgent Order',
visibility: :global,
created_by: user,
actions: [
{ 'action_name' => 'add_label', 'action_params' => [urgent_label.title] },
{ 'action_name' => 'change_priority', 'action_params' => ['urgent'] },
{ 'action_name' => 'assign_team', 'action_params' => [operations_team.id] },
{ 'action_name' => 'send_message',
'action_params' => ['I see you need this urgently. We offer same-day delivery for orders placed before noon. Let me check our inventory and get this expedited for you.'] }
]
)
Macro.create!(
account: account,
name: 'Handle Custom Printing Request',
visibility: :global,
created_by: user,
actions: [
{ 'action_name' => 'add_label', 'action_params' => [custom_print_label.title] },
{ 'action_name' => 'assign_team', 'action_params' => [support_team.id] },
{ 'action_name' => 'send_message',
'action_params' => ['We provide custom letterhead printing services with a minimum order of 5 reams. Typical turnaround is 5-7 business days. Would you like to discuss your design requirements?'] }
]
)
Macro.create!(
account: account,
name: 'Route to Sales Team',
visibility: :global,
created_by: user,
actions: [
{ 'action_name' => 'add_label', 'action_params' => [pricing_label.title] },
{ 'action_name' => 'assign_team', 'action_params' => [sales_team.id] },
{ 'action_name' => 'send_message',
'action_params' => ['I\'d be happy to provide pricing information. To give you the most accurate quote, could you let me know: 1) Paper type/weight you need, 2) Quantity required, 3) Delivery location?'] }
]
)
Macro.create!(
account: account,
name: 'Close with Satisfaction Check',
visibility: :global,
created_by: user,
actions: [
{ 'action_name' => 'send_message',
'action_params' => ['Thank you for choosing Paperlayer! Is there anything else I can help you with today?'] },
{ 'action_name' => 'resolve_conversation' }
]
)
Macro.create!(
account: account,
name: 'Escalate to Operations',
visibility: :global,
created_by: user,
actions: [
{ 'action_name' => 'add_label', 'action_params' => [delivery_label.title] },
{ 'action_name' => 'assign_team', 'action_params' => [operations_team.id] },
{ 'action_name' => 'change_priority', 'action_params' => ['high'] }
]
)
# ===============================================
# Create 50 Sample Conversations
# ===============================================
agents = [sales_lead, sales_agent_1, sales_agent_2, support_lead, support_agent_1, support_agent_2, operations_lead, operations_agent]
# Customer names for variety
customer_names = [
'Michael Stevens', 'Sarah Parker', 'John Martinez', 'Emily White', 'David Brown',
'Jennifer Taylor', 'Robert Wilson', 'Lisa Anderson', 'James Thomas', 'Mary Jackson',
'Christopher Lee', 'Patricia Harris', 'Daniel Clark', 'Linda Lewis', 'Matthew Walker',
'Barbara Hall', 'Joseph Allen', 'Nancy Young', 'Ryan King', 'Karen Wright',
'Kevin Lopez', 'Betty Hill', 'Brian Scott', 'Sandra Green', 'George Adams',
'Jessica Baker', 'Edward Nelson', 'Margaret Carter', 'Steven Mitchell', 'Helen Roberts',
'Andrew Turner', 'Dorothy Phillips', 'Mark Campbell', 'Carol Parker', 'Paul Evans',
'Michelle Edwards', 'Donald Collins', 'Ashley Stewart', 'Kenneth Morris', 'Kimberly Rogers',
'Joshua Reed', 'Amanda Cook', 'Jason Morgan', 'Melissa Bell', 'Justin Murphy',
'Stephanie Bailey', 'Brandon Rivera', 'Rebecca Cooper', 'Eric Richardson', 'Laura Cox'
]
companies = [
'Tech Innovations Inc', 'Global Enterprises', 'Summit Corporation', 'Bright Future LLC',
'Metro Solutions', 'Pacific Group', 'Alliance Partners', 'Premier Services',
'Apex Industries', 'Horizon Technologies', 'Fusion Consulting', 'Vista Corp',
'Pinnacle Systems', 'Catalyst Ventures', 'Quantum Labs', 'Nexus Holdings',
'Zenith Partners', 'Atlas Group', 'Infinity Solutions', 'Eclipse Enterprises'
]
conversation_templates = [
{
labels: [bulk_order_label.title, pricing_label.title],
team: sales_team,
priority: nil,
status: :open,
messages: [
{ role: :incoming, content: 'Hi, I need a quote for 200 cases of copy paper for our office. What kind of pricing can you offer?' },
{ role: :outgoing,
content: 'Great! For 200 cases, you qualify for our 15% volume discount. That brings the price down to $38.25 per case. Would you like me to prepare a formal quote?' },
{ role: :incoming, content: 'Yes please. How soon can you deliver?' }
]
},
{
labels: [custom_print_label.title],
team: support_team,
priority: nil,
status: :pending,
messages: [
{ role: :incoming, content: 'Do you offer custom letterhead printing? We need about 10 reams with our logo.' },
{ role: :outgoing,
content: 'Absolutely! We can do custom letterhead with a minimum order of 5 reams. For 10 reams on 28lb premium paper, the cost would be around $200. Turnaround is 5-7 business days. Do you have your design file ready?' }
]
},
{
labels: [urgent_label.title, delivery_label.title],
team: operations_team,
priority: :urgent,
status: :open,
messages: [
{ role: :incoming, content: 'Emergency! We need 50 reams delivered today. Is that possible?' },
{ role: :outgoing, content: 'Yes, we can do same-day delivery within 50 miles if you order before noon. What\'s your location?' },
{ role: :incoming, content: 'We\'re in downtown. How much for rush delivery?' }
]
},
{
labels: [product_label.title],
team: support_team,
priority: nil,
status: :resolved,
messages: [
{ role: :incoming, content: 'What\'s the difference between 20lb and 24lb paper?' },
{ role: :outgoing,
content: '20lb is our standard weight, great for everyday printing. 24lb is heavier, more professional feel, and less see-through. Perfect for presentations and client-facing documents.' },
{ role: :incoming, content: 'Perfect, I\'ll go with 24lb. Thanks!' }
]
},
{
labels: [pricing_label.title],
team: sales_team,
priority: nil,
status: :open,
messages: [
{ role: :incoming, content: 'Can you send me your current price list for all paper weights?' },
{ role: :outgoing,
content: 'Of course! I\'ll email you our comprehensive price list. Are you interested in standard sizes or do you need custom dimensions as well?' }
]
},
{
labels: [delivery_label.title],
team: operations_team,
priority: :high,
status: :pending,
messages: [
{ role: :incoming, content: 'My order #12345 hasn\'t arrived yet. It was supposed to be here yesterday.' },
{ role: :outgoing, content: 'I apologize for the delay. Let me track that order for you right away.' }
]
},
{
labels: [bulk_order_label.title],
team: sales_team,
priority: nil,
status: :resolved,
messages: [
{ role: :incoming, content: 'We\'re opening 5 new offices and need to set up a recurring order. Can you help?' },
{ role: :outgoing,
content: 'Excellent! We have a bulk program perfect for multi-location businesses. With recurring orders over 250 cases, you get 20% off plus a dedicated account manager. When would you like to discuss the details?' },
{ role: :incoming, content: 'That sounds great! Tomorrow at 2 PM works for me.' },
{ role: :outgoing, content: 'Perfect, I\'ve scheduled a call for tomorrow at 2 PM. I\'ll send you a calendar invite shortly.' }
]
},
{
labels: [product_label.title, pricing_label.title],
team: sales_team,
priority: nil,
status: :open,
messages: [
{ role: :incoming, content: 'Do you have recycled paper options? We\'re trying to go green.' },
{ role: :outgoing,
content: 'Yes! Our Eco-Friendly line is 30% post-consumer recycled content and FSC certified. It\'s $48 per case, just $3 more than standard paper.' }
]
},
{
labels: [custom_print_label.title, urgent_label.title],
team: support_team,
priority: :urgent,
status: :open,
messages: [
{ role: :incoming, content: 'We have a trade show next week and need 1000 business cards ASAP. Can you rush this?' },
{ role: :outgoing,
content: 'For rush orders, we can turn it around in 2-3 business days for an additional $50 rush fee. Would that work for your timeline?' }
]
},
{
labels: [delivery_label.title],
team: operations_team,
priority: nil,
status: :resolved,
messages: [
{ role: :incoming, content: 'What are your delivery hours? We have limited receiving times.' },
{ role: :outgoing,
content: 'Our standard delivery window is 9 AM - 5 PM. We can accommodate specific time windows with advance notice. What times work best for you?' },
{ role: :incoming, content: '10 AM - 2 PM would be ideal.' },
{ role: :outgoing, content: 'Noted! I\'ve added that to your account preferences.' }
]
}
]
puts ' Creating 50 diverse conversations...'
50.times do |i|
# Create contact
customer_name = customer_names[i % customer_names.length]
company = companies[i % companies.length]
contact_inbox = ContactInboxWithContactBuilder.new(
source_id: agents.sample.id,
inbox: [inbox, sales_inbox, support_inbox].sample,
hmac_verified: true,
contact_attributes: {
name: customer_name,
email: "#{customer_name.downcase.tr(' ', '.')}@#{company.downcase.delete(' ')}.com",
phone_number: "+1555#{rand(1_000_000..9_999_999)}",
additional_attributes: {
company_name: company,
city: ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia'].sample
}
}
).perform
# Pick a template or create variation
template = conversation_templates[i % conversation_templates.length]
# Assign to agent from appropriate team
team_members = TeamMember.where(team: template[:team]).pluck(:user_id)
assigned_agent = User.find(team_members.sample)
# Create conversation
conv = Conversation.create!(
account: account,
inbox: contact_inbox.inbox,
status: template[:status],
assignee: assigned_agent,
contact: contact_inbox.contact,
contact_inbox: contact_inbox,
priority: template[:priority],
additional_attributes: {
initiated_at: {
timestamp: rand(30).days.ago.to_i
}
}
)
# Add labels
conv.label_list.add(*template[:labels])
conv.save!
# Create messages
template[:messages].each do |msg_template|
sender = msg_template[:role] == :incoming ? contact_inbox.contact : assigned_agent
Message.create!(
content: msg_template[:content],
account: account,
inbox: contact_inbox.inbox,
conversation: conv,
sender: sender,
message_type: msg_template[:role]
)
end
end
# Create one special conversation with interactive messages
special_contact = ContactInboxWithContactBuilder.new(
source_id: sales_lead.id,
inbox: inbox, inbox: inbox,
hmac_verified: true, hmac_verified: true,
contact_attributes: { name: 'jane', email: 'jane@example.com', phone_number: '+2320000' } contact_attributes: {
name: 'Demo Customer',
email: 'demo@example.com',
phone_number: '+15551234567'
}
).perform ).perform
conversation = Conversation.create!( demo_conversation = Conversation.create!(
account: account, account: account,
inbox: inbox, inbox: inbox,
status: :open, status: :open,
assignee: user, assignee: sales_lead,
contact: contact_inbox.contact, contact: special_contact.contact,
contact_inbox: contact_inbox, contact_inbox: special_contact,
additional_attributes: {} additional_attributes: {}
) )
demo_conversation.label_list.add(bulk_order_label.title)
demo_conversation.save!
# sample email collect Message.create!(content: 'Hi! I\'m interested in learning more about your products.', account: account, inbox: inbox,
Seeders::MessageSeeder.create_sample_email_collect_message conversation conversation: demo_conversation, sender: special_contact.contact, message_type: :incoming)
Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, sender: contact_inbox.contact, # Add interactive message samples
message_type: :incoming) Seeders::MessageSeeder.create_sample_cards_message demo_conversation
Seeders::MessageSeeder.create_sample_input_select_message demo_conversation
Seeders::MessageSeeder.create_sample_form_message demo_conversation
Seeders::MessageSeeder.create_sample_articles_message demo_conversation
Seeders::MessageSeeder.create_sample_email_collect_message demo_conversation
Seeders::MessageSeeder.create_sample_csat_collect_message demo_conversation
# sample location message puts ' ✓ Created 50+ conversations with diverse scenarios'
#
location_message = Message.new(content: 'location', account: account, inbox: inbox, sender: contact_inbox.contact, conversation: conversation, # ===============================================
message_type: :incoming) # Help Center Portal Setup
location_message.attachments.new( # ===============================================
account_id: account.id, portal = Portal.create!(
file_type: 'location', account: account,
coordinates_lat: 37.7893768, name: 'Paperlayer Help Center',
coordinates_long: -122.3895553, slug: 'dunder-mifflin-help',
fallback_title: 'Bay Bridge, San Francisco, CA, USA' page_title: 'Paperlayer Paper Company - Help & Support',
header_text: 'How can we help you with your paper needs?',
homepage_link: 'https://paperlayer.com',
color: '#0066CC',
config: {
'allowed_locales' => ['en'],
'default_locale' => 'en'
}
) )
location_message.save!
# sample card # Category: Getting Started
Seeders::MessageSeeder.create_sample_cards_message conversation getting_started_category = Category.create!(
# input select account: account,
Seeders::MessageSeeder.create_sample_input_select_message conversation portal: portal,
# form name: 'Getting Started',
Seeders::MessageSeeder.create_sample_form_message conversation slug: 'getting-started',
# articles description: 'Learn the basics of ordering from Paperlayer',
Seeders::MessageSeeder.create_sample_articles_message conversation locale: 'en',
# csat position: 1,
Seeders::MessageSeeder.create_sample_csat_collect_message conversation icon: 'book-open'
)
CannedResponse.create!(account: account, short_code: 'start', content: 'Hello welcome to chatwoot.') Article.create!(
account: account,
portal: portal,
category: getting_started_category,
author: user,
title: 'How to Place Your First Order',
description: 'Step-by-step guide to placing your first paper order with Paperlayer',
content: "# How to Place Your First Order\n\nWelcome to Paperlayer! We're excited to help you with your paper needs.\n\n## Step 1: Browse Our Products\nVisit our product catalog to explore our wide range of paper products:\n- Copy Paper (20lb, 24lb, 28lb, 32lb)\n- Cardstock (65lb, 80lb, 110lb)\n- Specialty Papers (colored, glossy, recycled)\n\n## Step 2: Request a Quote\nContact our sales team via:\n- Live chat on our website\n- Email: sales@paperlayer.com\n- Phone: 1-800-PAPER-01\n\n## Step 3: Review and Approve\nOur team will send you a detailed quote within 24 hours.\n\n## Step 4: Place Your Order\nOnce approved, we'll process your order and provide tracking information.\n\n## Step 5: Delivery\nStandard delivery: 2-3 business days\nSame-day delivery: Available for local orders placed before noon",
status: :published,
locale: 'en'
)
Article.create!(
account: account,
portal: portal,
category: getting_started_category,
author: user,
title: 'Understanding Paper Weights',
description: 'Learn about different paper weights and their best uses',
content: "# Understanding Paper Weights\n\nPaper weight can be confusing. Let us help you choose the right weight for your needs.\n\n## Copy Paper Weights\n\n### 20lb (75 gsm) - Standard\n- Most common weight for everyday printing\n- Great for internal documents, drafts\n- Economical choice for high-volume printing\n\n### 24lb (90 gsm) - Premium\n- Heavier feel, more professional\n- Ideal for presentations, resumes\n- Less see-through than 20lb\n\n### 28lb (105 gsm) - Super Premium\n- Excellent for important documents\n- Professional brochures\n- High-quality letterhead\n\n### 32lb (120 gsm) - Ultra Premium\n- Luxury feel and appearance\n- Executive communications\n- Certificates and awards\n\n## Cardstock Weights\n\n### 65lb (176 gsm) - Light Cardstock\n- Greeting cards, postcards\n- Thin presentations\n\n### 80lb (216 gsm) - Medium Cardstock\n- Business cards\n- Invitations\n\n### 110lb (298 gsm) - Heavy Cardstock\n- Premium business cards\n- High-end invitations\n- Display materials\n\n## Need Help?\nContact our team at sales@paperlayer.com or call 1-800-PAPER-01",
status: :published,
locale: 'en'
)
# Category: Products & Pricing
products_category = Category.create!(
account: account,
portal: portal,
name: 'Products & Pricing',
slug: 'products-pricing',
description: 'Information about our paper products and pricing',
locale: 'en',
position: 2,
icon: 'tag'
)
Article.create!(
account: account,
portal: portal,
category: products_category,
author: user,
title: 'Copy Paper Product Line',
description: 'Complete guide to our copy paper offerings',
content: "# Copy Paper Product Line\n\nPaperlayer offers the finest copy paper in the Northeast.\n\n## Standard Copy Paper\n**Premium White - 20lb**\n- Price: $45 per case (10 reams)\n- Brightness: 92\n- Size: 8.5\" x 11\"\n- Sheets per ream: 500\n\n**Premium White - 24lb**\n- Price: $52 per case (10 reams)\n- Brightness: 94\n- Size: 8.5\" x 11\"\n- Sheets per ream: 500\n\n## Premium Copy Paper\n**Ultra White - 28lb**\n- Price: $65 per case (10 reams)\n- Brightness: 96\n- Perfect for presentations\n\n**Executive White - 32lb**\n- Price: $78 per case (10 reams)\n- Brightness: 98\n- Luxury feel and appearance\n\n## Recycled Paper\n**Eco-Friendly - 20lb**\n- Price: $48 per case (10 reams)\n- 30% post-consumer content\n- FSC certified\n\n## Volume Discounts\n- 50-99 cases: 10% off\n- 100-249 cases: 15% off\n- 250+ cases: 20% off\n\nContact us for custom quotes!",
status: :published,
locale: 'en'
)
Article.create!(
account: account,
portal: portal,
category: products_category,
author: user,
title: 'Bulk Order Discounts',
description: 'Learn about our volume pricing tiers and save on large orders',
content: "# Bulk Order Discounts\n\nThe more you order, the more you save with Paperlayer!\n\n## Discount Tiers\n\n### Tier 1: 50-99 Cases\n- **10% discount**\n- Minimum order: 50 cases\n- Perfect for medium-sized offices\n\n### Tier 2: 100-249 Cases\n- **15% discount**\n- Quarterly supply option\n- Dedicated account manager\n\n### Tier 3: 250+ Cases\n- **20% discount**\n- Annual contract pricing\n- Priority customer service\n- Free delivery\n- Flexible payment terms\n\n## Example Savings\n\n**Standard scenario: 100 cases of Premium White 20lb**\n- Regular price: $4,500\n- With 15% discount: $3,825\n- **You save: $675!**\n\n## Additional Benefits\n- No order minimums after initial bulk purchase\n- Locked-in pricing for contract duration\n- Scheduled deliveries\n- Custom invoicing options\n\n## Get Started\nContact our bulk sales team:\n- Email: bulk@paperlayer.com\n- Phone: 1-800-BULK-PAPER\n- Live chat: Available 9 AM - 6 PM EST",
status: :published,
locale: 'en'
)
# Category: Shipping & Delivery
shipping_category = Category.create!(
account: account,
portal: portal,
name: 'Shipping & Delivery',
slug: 'shipping-delivery',
description: 'Delivery options, shipping costs, and tracking information',
locale: 'en',
position: 3,
icon: 'truck'
)
Article.create!(
account: account,
portal: portal,
category: shipping_category,
author: user,
title: 'Delivery Options & Times',
description: 'Choose the delivery option that works best for you',
content: "# Delivery Options & Times\n\n## Standard Delivery (FREE)\n- **Timeframe:** 2-3 business days\n- **Available:** All orders over $100\n- **Coverage:** Within 200 miles of Scranton, PA\n\n## Express Delivery\n- **Timeframe:** Next business day\n- **Cost:** $25 flat fee\n- **Coverage:** Within 100 miles\n- **Cutoff:** Order by 3 PM\n\n## Same-Day Delivery\n- **Timeframe:** Within 6 hours\n- **Cost:** $50 flat fee\n- **Coverage:** Within 50 miles\n- **Cutoff:** Order by 12 PM noon\n\n## Freight Shipping (Bulk Orders)\n- **Timeframe:** 3-5 business days\n- **Cost:** Varies by distance and volume\n- **Available:** Orders over 100 cases\n- **Includes:** Forklift delivery available\n\n## Tracking Your Order\nAll orders include:\n- Real-time tracking number\n- Email notifications\n- Estimated delivery window\n- Delivery signature confirmation\n\n## Delivery Notes\n- Residential deliveries available\n- Loading dock delivery preferred for bulk\n- Inside delivery available (additional fee)\n- Weather delays may occur\n\nQuestions? Contact: logistics@paperlayer.com",
status: :published,
locale: 'en'
)
# Category: Custom Services
custom_category = Category.create!(
account: account,
portal: portal,
name: 'Custom Services',
slug: 'custom-services',
description: 'Custom printing, letterhead, and specialty services',
locale: 'en',
position: 4,
icon: 'printer'
)
Article.create!(
account: account,
portal: portal,
category: custom_category,
author: user,
title: 'Custom Letterhead & Business Stationery',
description: 'Professional custom printing services for your business',
content: "# Custom Letterhead & Business Stationery\n\nMake a lasting impression with custom-printed materials from Paperlayer.\n\n## Letterhead Services\n\n### Standard Letterhead\n- **Minimum order:** 5 reams (2,500 sheets)\n- **Turnaround:** 5-7 business days\n- **Paper weight:** 24lb or 28lb\n- **Colors:** Up to 4-color process\n- **Starting at:** $15 per ream\n\n### Premium Letterhead\n- **Minimum order:** 5 reams\n- **Turnaround:** 7-10 business days\n- **Paper weight:** 32lb premium stock\n- **Options:** Embossing, foil stamping\n- **Starting at:** $25 per ream\n\n## Business Cards\n- Standard: 14pt cardstock\n- Premium: 16pt with UV coating\n- Minimum: 500 cards\n- Turnaround: 3-5 business days\n\n## Envelopes\n- Custom printed envelopes\n- Standard #10 size\n- Match your letterhead design\n- Minimum: 500 envelopes\n\n## Design Services\n- Professional design assistance: $150\n- Logo digitization: $75\n- Proof revisions: Included (up to 3)\n\n## The Process\n\n1. **Submit your design** - Email to custom@paperlayer.com\n2. **Review proof** - We'll send a digital proof within 2 business days\n3. **Approve** - Make any needed revisions\n4. **Production** - We print your order\n5. **Delivery** - Ships via your chosen method\n\n## File Requirements\n- Format: PDF, AI, or EPS\n- Resolution: 300 DPI minimum\n- Color mode: CMYK\n- Bleed: 0.125\" on all sides\n\nNeed help with design? We can assist!\nEmail: custom@paperlayer.com\nPhone: 1-800-CUSTOM-PAPER",
status: :published,
locale: 'en'
)
# ===============================================
# Captain Assistant Setup (Enterprise Feature)
# ===============================================
if defined?(Captain::Assistant)
captain_assistant = Captain::Assistant.create!(
account: account,
name: 'Paper Expert',
description: 'AI assistant specialized in helping customers with all their paper product needs, from product selection to bulk orders.',
config: {
temperature: 0.7,
feature_faq: true,
feature_memory: true,
product_name: 'Paperlayer Paper Products'
},
response_guidelines: [
'Always be friendly and professional',
'Use paper industry terminology when appropriate',
'Provide specific product recommendations based on customer needs',
'Mention bulk discounts when discussing large orders',
'Offer to connect customers with sales team for custom quotes'
],
guardrails: [
'Do not discuss competitor products',
'Do not make promises about delivery times without confirming inventory',
'Always verify bulk pricing with sales team for orders over 250 cases'
]
)
# Connect assistant to inbox
CaptainInbox.create!(
captain_assistant: captain_assistant,
inbox: inbox
)
# Scenario 1: Product Recommendations
Captain::Scenario.create!(
account: account,
assistant: captain_assistant,
title: 'Product Recommendations',
description: 'Help customers choose the right paper products for their needs',
instruction: "When a customer asks about which paper to use, ask clarifying questions:\n1. What will they use it for? (everyday printing, presentations, letterhead, etc.)\n2. What's their volume? (help them understand bulk discounts)\n3. Do they have any special requirements? (recycled, brightness, specific sizes)\n\nThen recommend appropriate products from our catalog:\n- 20lb for everyday use\n- 24lb for professional documents\n- 28lb for presentations\n- 32lb for executive communications\n- Cardstock for business cards\n\nAlways mention relevant discounts and offer to connect them with sales for quotes.",
enabled: true
)
# Scenario 2: Bulk Orders
Captain::Scenario.create!(
account: account,
assistant: captain_assistant,
title: 'Bulk Order Processing',
description: 'Guide customers through bulk order process and pricing',
instruction: "For bulk order inquiries:\n1. Determine their quantity needs\n2. Explain applicable discount tiers:\n - 50-99 cases: 10% off\n - 100-249 cases: 15% off\n - 250+ cases: 20% off\n3. Calculate estimated savings\n4. Explain additional benefits (account manager, flexible terms)\n5. Offer to connect with bulk sales team for formal quote\n6. Add the 'bulk-order' label to the conversation",
enabled: true
)
# Scenario 3: Shipping Questions
Captain::Scenario.create!(
account: account,
assistant: captain_assistant,
title: 'Shipping & Delivery',
description: 'Answer questions about delivery options and times',
instruction: "When customers ask about shipping:\n1. Standard delivery: 2-3 business days (FREE over $100)\n2. Express: Next day ($25)\n3. Same-day: Within 6 hours, order by noon ($50)\n4. Bulk freight: 3-5 days for 100+ cases\n\nMention tracking is included with all orders.\nFor urgent needs, emphasize same-day delivery within 50 miles.",
enabled: true
)
# Document 1: FAQ - General Questions
Captain::Document.create!(
account: account,
assistant: captain_assistant,
name: 'General FAQs',
external_link: 'https://paperlayer.com/faq/general',
content: "# Frequently Asked Questions\n\n## What are your business hours?\nMonday-Friday: 8 AM - 6 PM EST\nSaturday: 9 AM - 2 PM EST\nSunday: Closed\n\n## Do you offer free shipping?\nYes! Free standard shipping on all orders over $100 within 200 miles of Scranton, PA.\n\n## What payment methods do you accept?\n- Credit cards (Visa, Mastercard, AmEx, Discover)\n- Purchase orders (for established accounts)\n- ACH/Wire transfer\n- Net 30 terms (for qualified businesses)\n\n## Can I return paper products?\nYes, unopened reams can be returned within 30 days for a full refund.\n\n## Do you have a showroom?\nYes! Visit our Scranton office at:\n1725 Slough Avenue\nScranton, PA 18505\n\nAppointments recommended.\n\n## What makes Paperlayer different?\n- Family-owned since 1949\n- Personal service from dedicated account managers\n- Local delivery and same-day service\n- Competitive pricing with volume discounts\n- Expert advice on paper selection",
status: :available
)
# Document 2: FAQ - Product Specifications
Captain::Document.create!(
account: account,
assistant: captain_assistant,
name: 'Product Specifications',
external_link: 'https://paperlayer.com/faq/products',
content: "# Product Specifications FAQs\n\n## What does paper brightness mean?\nBrightness is measured on a scale of 0-100. Higher numbers mean whiter, brighter paper.\n- 92: Standard brightness\n- 94-96: Premium brightness\n- 98+: Ultra-premium brightness\n\n## What's the difference between 20lb and 24lb paper?\nThe number refers to the weight of 500 sheets (a ream).\n- 20lb: Standard weight, economical\n- 24lb: Heavier, more professional feel\n- 28lb: Premium weight for presentations\n- 32lb: Luxury weight for executive use\n\n## Is your paper acid-free?\nYes, all our copy paper is acid-free for long-term archival quality.\n\n## Do you carry recycled paper?\nYes! Our Eco-Friendly line contains 30% post-consumer recycled content and is FSC certified.\n\n## What sizes do you offer?\n- Letter: 8.5\" x 11\" (most common)\n- Legal: 8.5\" x 14\"\n- Tabloid: 11\" x 17\"\n- Custom sizes available for bulk orders\n\n## Can I get paper in colors?\nYes! We stock:\n- Pastels: Ivory, Pink, Blue, Green, Yellow\n- Brights: Red, Orange, Purple\n- Custom colors available for bulk orders\n\n## What's the shelf life of paper?\nPaper properly stored in a cool, dry place will last indefinitely.",
status: :available
)
# Document 3: Bulk Ordering Guide
Captain::Document.create!(
account: account,
assistant: captain_assistant,
name: 'Bulk Ordering Guide',
external_link: 'https://paperlayer.com/guides/bulk-orders',
content: "# Bulk Ordering Guide\n\n## Benefits of Bulk Orders\n\n### Cost Savings\n- 50-99 cases: Save 10%\n- 100-249 cases: Save 15%\n- 250+ cases: Save 20%\n\n### Additional Perks\n- Dedicated account manager\n- Priority customer service\n- Flexible payment terms (Net 30/60/90)\n- Free delivery on orders over 100 cases\n- Scheduled recurring deliveries\n- Custom reporting and invoicing\n\n## How to Place a Bulk Order\n\n1. **Contact our bulk sales team**\n - Email: bulk@paperlayer.com\n - Phone: 1-800-BULK-PAPER\n - Chat with us online\n\n2. **Discuss your needs**\n - Types of paper needed\n - Estimated monthly/annual volume\n - Delivery schedule preferences\n\n3. **Receive your custom quote**\n - Within 24 hours for standard requests\n - Same day for urgent needs\n\n4. **Review and sign agreement**\n - No long-term contracts required\n - Flexible terms available\n\n5. **Start saving!**\n - Automated ordering available\n - Just-in-time delivery options\n\n## Storage Tips for Bulk Orders\n- Store in cool, dry place (60-70°F)\n- Keep away from direct sunlight\n- Maintain 40-60% humidity\n- Store flat, not on edge\n- Keep in original packaging until use\n\n## Popular Bulk Order Scenarios\n\n### Small Office (50-100 cases/year)\n- Mix of 20lb and 24lb paper\n- Quarterly deliveries\n- 10% savings tier\n\n### Medium Business (100-250 cases/year)\n- Multiple paper types\n- Monthly deliveries\n- 15% savings tier\n- Account manager included\n\n### Large Corporation (250+ cases/year)\n- Enterprise agreement\n- Custom delivery schedule\n- 20% savings tier\n- Premium support\n\n## Questions?\nContact our bulk sales specialists:\nbulk@paperlayer.com",
status: :available
)
# Document 4: Custom Printing Guide
Captain::Document.create!(
account: account,
assistant: captain_assistant,
name: 'Custom Printing Services',
external_link: 'https://paperlayer.com/services/custom-printing',
content: "# Custom Printing Services\n\n## What We Offer\n\n### Letterhead\n- Professional custom letterhead\n- Minimum: 5 reams (2,500 sheets)\n- Paper options: 24lb, 28lb, or 32lb\n- Full color printing\n- Turnaround: 5-10 business days\n\n### Business Cards\n- Premium cardstock\n- Standard or custom sizes\n- Options: matte, gloss, UV coating\n- Minimum: 500 cards\n- Turnaround: 3-5 business days\n\n### Envelopes\n- Custom printed envelopes\n- Match your letterhead\n- Multiple sizes available\n- Minimum: 500 envelopes\n\n## Design Services\n\nNeed help with design?\n- Professional design: $150\n- Logo digitization: $75\n- Free setup for repeat orders\n- Up to 3 proof revisions included\n\n## File Requirements\n- Preferred format: PDF, AI, EPS\n- Resolution: 300 DPI minimum\n- Color mode: CMYK (not RGB)\n- Include 0.125\" bleed\n- Embed all fonts\n\n## Approval Process\n\n1. Submit your design or request assistance\n2. Receive digital proof within 2 business days\n3. Review and approve (or request changes)\n4. Production begins upon approval\n5. Delivery within specified timeframe\n\n## Pricing Examples\n\n**Standard Letterhead (24lb)**\n- 5 reams: $75\n- 10 reams: $140\n- 25 reams: $325\n\n**Premium Letterhead (32lb)**\n- 5 reams: $125\n- 10 reams: $230\n- 25 reams: $525\n\n**Business Cards**\n- 500 cards: $75\n- 1,000 cards: $125\n- 2,500 cards: $275\n\n## Get Started\n\nEmail your project details to:\ncustom@paperlayer.com\n\nOr call: 1-800-CUSTOM-PAPER",
status: :available
)
puts ' ✓ Captain Assistant created with documents and scenarios'
else
puts ' ⚠ Captain Assistant is an Enterprise feature - skipping'
end
end end
@@ -93,10 +93,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end end
def send_out_of_office_message_if_applicable def send_out_of_office_message_if_applicable
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if @conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation) ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end end
@@ -3,12 +3,6 @@ module Enterprise::Account
# this is a temporary method since current administrate doesn't support virtual attributes # this is a temporary method since current administrate doesn't support virtual attributes
def manually_managed_features; end 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') def mark_for_deletion(reason = 'manual_deletion')
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity' reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
@@ -37,21 +31,4 @@ module Enterprise::Account
def saml_enabled? def saml_enabled?
saml_settings&.saml_enabled? || false saml_settings&.saml_enabled? || false
end 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 end
@@ -32,13 +32,6 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
save save
end 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 def email_rate_limit
account_limit || plan_email_limit || global_limit || default_limit account_limit || plan_email_limit || global_limit || default_limit
end end
@@ -1,49 +0,0 @@
class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
MODEL = 'gpt-4.1-nano'.freeze
pattr_initialize [:account!]
def translate(query, target_language:)
return query if query_in_target_language?(query)
messages = [
{ role: 'system', content: system_prompt(target_language) },
{ role: 'user', content: query }
]
response = make_api_call(model: MODEL, messages: messages)
return query if response[:error]
response[:message].strip
rescue StandardError => e
Rails.logger.warn "TranslateQueryService failed: #{e.message}, falling back to original query"
query
end
private
def event_name
'translate_query'
end
def query_in_target_language?(query)
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
result = detector.find_language(query)
result.reliable? && result.language == account_language_code
rescue StandardError
false
end
def account_language_code
account.locale&.split('_')&.first
end
def system_prompt(target_language)
<<~SYSTEM_PROMPT_MESSAGE
You are a helpful assistant that translates queries from one language to another.
Translate the query to #{target_language}.
Return just the translated query, no other text.
SYSTEM_PROMPT_MESSAGE
end
end
@@ -9,11 +9,7 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
def execute(query:) def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" } Rails.logger.info { "#{self.class.name}: #{query}" }
translated_query = Captain::Llm::TranslateQueryService responses = assistant.responses.approved.search(query)
.new(account: assistant.account)
.translate(query, target_language: assistant.account.locale_english_name)
responses = assistant.responses.approved.search(translated_query)
return 'No FAQs found for the given query' if responses.empty? return 'No FAQs found for the given query' if responses.empty?
@@ -18,11 +18,7 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
def execute(query:) def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" } Rails.logger.info { "#{self.class.name}: #{query}" }
translated_query = Captain::Llm::TranslateQueryService responses = search_responses(query)
.new(account: @account)
.translate(query, target_language: @account.locale_english_name)
responses = search_responses(translated_query)
return 'No FAQs found for the given query' if responses.empty? return 'No FAQs found for the given query' if responses.empty?
responses.map { |response| format_response(response) }.join responses.map { |response| format_response(response) }.join
@@ -19,8 +19,7 @@ module Enterprise::AutoAssignment::AssignmentService
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled? agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty? return nil if agents.empty?
# Use balanced selector only if advanced_assignment feature is enabled selector = policy&.balanced? ? balanced_selector : round_robin_selector
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
selector.select_agent(agents) selector.select_agent(agents)
end end
@@ -32,7 +31,7 @@ module Enterprise::AutoAssignment::AssignmentService
end end
def capacity_filtering_enabled? def capacity_filtering_enabled?
account.feature_enabled?('advanced_assignment') && account.feature_enabled?('assignment_v2') &&
account.account_users.joins(:agent_capacity_policy).exists? account.account_users.joins(:agent_capacity_policy).exists?
end end
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze ].freeze
# Additional features available starting with the Business plan # Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes].freeze
# Additional features available only in the Enterprise plan # Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
@@ -68,10 +68,6 @@ module Enterprise::MessageTemplates::HookExecutionService
end end
def send_out_of_office_message_after_handoff def send_out_of_office_message_after_handoff
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end end
@@ -42,10 +42,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
end end
def send_out_of_office_message_if_applicable(conversation) def send_out_of_office_message_if_applicable(conversation)
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end end
+13 -16
View File
@@ -1,6 +1,5 @@
module Captain::ToolInstrumentation module Captain::ToolInstrumentation
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Integrations::LlmInstrumentationConstants
private private
@@ -11,10 +10,15 @@ module Captain::ToolInstrumentation
response = nil response = nil
executed = false executed = false
tracer.in_span(params[:span_name]) do |span| tracer.in_span(params[:span_name]) do |span|
set_tool_session_attributes(span, params) span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id]
span.set_attribute('langfuse.tags', [params[:feature_name]].to_json)
span.set_attribute('langfuse.observation.input', params[:messages].to_json)
response = yield response = yield
executed = true executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
# Output just the message for cleaner Langfuse display
span.set_attribute('langfuse.observation.output', response[:message] || response.to_json)
end end
response response
rescue StandardError => e rescue StandardError => e
@@ -22,24 +26,17 @@ module Captain::ToolInstrumentation
executed ? response : yield executed ? response : yield
end end
def set_tool_session_attributes(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
def record_generation(chat, message, model) def record_generation(chat, message, model)
return unless ChatwootApp.otel_enabled? return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant' return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span| tracer.in_span("llm.#{event_name}.generation") do |span|
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') span.set_attribute('gen_ai.system', 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) span.set_attribute('gen_ai.request.model', model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat)) span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content) span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.warn "Failed to record generation: #{e.message}" Rails.logger.warn "Failed to record generation: #{e.message}"
@@ -33,7 +33,7 @@ General guidelines:
- Reply in the customer's language - Reply in the customer's language
{% if has_search_tool %} {% if has_search_tool %}
**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. **Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information.
**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation. **Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation.
{% endif %} {% endif %}
@@ -102,8 +102,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
def send_message def send_message
post_message if message_content.present? post_message if message_content.present?
upload_files if message.attachments.any? upload_files if message.attachments.any?
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
Rails.logger.error e Rails.logger.error e
hook.prompt_reauthorization! hook.prompt_reauthorization!
+5 -2
View File
@@ -2,10 +2,13 @@ module Linear::Mutations
def self.graphql_value(value) def self.graphql_value(value)
case value case value
when String when String
value.to_json # Strings must be enclosed in double quotes
"\"#{value.gsub("\n", '\\n')}\""
when Array when Array
# Arrays need to be recursively converted
"[#{value.map { |v| graphql_value(v) }.join(', ')}]" "[#{value.map { |v| graphql_value(v) }.join(', ')}]"
else else
# Other types (numbers, booleans) can be directly converted to strings
value.to_s value.to_s
end end
end end
@@ -44,7 +47,7 @@ module Linear::Mutations
<<~GRAPHQL <<~GRAPHQL
mutation { mutation {
attachmentLinkURL(url: #{graphql_value(link)}, issueId: #{graphql_value(issue_id)}, title: #{graphql_value(title)}#{user_params_str}) { attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
success success
attachment { attachment {
id id
+1 -1
View File
@@ -48,7 +48,7 @@ module Linear::Queries
def self.search_issue(term) def self.search_issue(term)
<<~GRAPHQL <<~GRAPHQL
query { query {
searchIssues(term: #{Linear::Mutations.graphql_value(term)}) { searchIssues(term: "#{term}") {
nodes { nodes {
id id
title title
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module LlmConstants module LlmConstants
DEFAULT_MODEL = 'gpt-4.1' DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small' DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini' PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
+189 -5
View File
@@ -23,22 +23,45 @@ class Seeders::AccountSeeder
set_up_users set_up_users
seed_labels seed_labels
seed_canned_responses seed_canned_responses
seed_macros
seed_inboxes seed_inboxes
seed_contacts seed_contacts
seed_portals
seed_captain_assistants
finalize_seeding
end end
def set_up_account def set_up_account
# Delete all existing data before seeding
@account.macros.destroy_all
@account.canned_responses.destroy_all
@account.teams.destroy_all @account.teams.destroy_all
@account.conversations.destroy_all @account.conversations.destroy_all
@account.labels.destroy_all @account.labels.destroy_all
@account.portals.destroy_all
@account.inboxes.destroy_all @account.inboxes.destroy_all
@account.contacts.destroy_all @account.contacts.destroy_all
@account.custom_roles.destroy_all if @account.respond_to?(:custom_roles) @account.custom_roles.destroy_all if @account.respond_to?(:custom_roles)
# Delete Captain Assistants if Enterprise feature is available
return unless defined?(Captain::Assistant)
Captain::Assistant.where(account_id: @account.id).destroy_all
end end
def seed_teams def seed_teams
@account_data['teams'].each do |team_name| @account_data['teams'].each do |team_data|
@account.teams.create!(name: team_name) if team_data.is_a?(String)
# Support legacy format (just team name)
@account.teams.create!(name: team_data)
else
# New format with name and description
@account.teams.create!(
name: team_data['name'],
description: team_data['description'],
allow_auto_assign: team_data['allow_auto_assign'] != false
)
end
end end
end end
@@ -103,9 +126,68 @@ class Seeders::AccountSeeder
@account.custom_roles.find_by(name: role_name) @account.custom_roles.find_by(name: role_name)
end end
def seed_canned_responses(count: 50) def seed_canned_responses
count.times do return unless @account_data['canned_responses'].present?
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
@account_data['canned_responses'].each do |response|
@account.canned_responses.create!(response)
end
end
def seed_macros
return unless @account_data['macros'].present?
@account_data['macros'].each do |macro_data|
created_by = User.from_email(macro_data['created_by']) if macro_data['created_by'].present?
actions = macro_data['actions'].map do |action|
process_macro_action(action)
end.compact # Remove nil actions (when team/agent not found)
# Only create macro if there are valid actions
if actions.any?
@account.macros.create!(
name: macro_data['name'],
visibility: macro_data['visibility'] || 'global',
created_by: created_by,
actions: actions
)
else
Rails.logger.warn("Macro '#{macro_data['name']}' has no valid actions, skipping")
end
end
end
def seed_portals
return unless @account_data['portals'].present?
@account_data['portals'].each do |portal_data|
portal = @account.portals.create!(
name: portal_data['name'],
slug: portal_data['slug'],
page_title: portal_data['page_title'],
header_text: portal_data['header_text'],
color: portal_data['color'],
config: portal_data['config']
)
seed_portal_categories(portal, portal_data['categories']) if portal_data['categories'].present?
end
end
def seed_captain_assistants
return unless @account_data['captain_assistants'].present?
return unless defined?(Captain::Assistant)
@account_data['captain_assistants'].each do |assistant_data|
assistant = Captain::Assistant.create!(
account: @account,
name: assistant_data['name'],
description: assistant_data['description'],
config: assistant_data['config'] || {}
)
seed_captain_documents(assistant, assistant_data['documents']) if assistant_data['documents'].present?
seed_captain_scenarios(assistant, assistant_data['scenarios']) if assistant_data['scenarios'].present?
end end
end end
@@ -155,4 +237,106 @@ class Seeders::AccountSeeder
def seed_inboxes def seed_inboxes
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform! Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
end end
def process_macro_action(action)
processed_action = { 'action_name' => action['action_name'] }
case action['action_name']
when 'assign_team'
team = @account.teams.find_by('name LIKE ?', "%#{action['action_params'][0]}%")
if team
processed_action['action_params'] = [team.id]
else
Rails.logger.warn("Macro: Team not found matching '#{action['action_params'][0]}', skipping assign_team action")
return nil # Skip this action
end
when 'assign_agent'
user = User.from_email(action['action_params'][0])
if user
processed_action['action_params'] = [user.id]
else
Rails.logger.warn("Macro: User not found '#{action['action_params'][0]}', skipping assign_agent action")
return nil # Skip this action
end
else
processed_action['action_params'] = action['action_params']
end
processed_action
end
def seed_portal_categories(portal, categories_data)
categories_data.each do |category_data|
category = portal.categories.create!(
account: @account,
name: category_data['name'],
slug: category_data['slug'],
description: category_data['description'],
locale: category_data['locale'] || 'en',
position: category_data['position']
)
seed_category_articles(portal, category, category_data['articles']) if category_data['articles'].present?
end
end
def seed_category_articles(portal, category, articles_data)
articles_data.each do |article_data|
author = User.from_email(article_data['author']) if article_data['author'].present?
author ||= @account.users.administrators.first
portal.articles.create!(
account: @account,
category: category,
author: author,
title: article_data['title'],
description: article_data['description'],
content: article_data['content'],
status: article_data['status'] || 'published',
locale: article_data['locale'] || 'en'
)
end
end
def seed_captain_documents(assistant, documents_data)
documents_data.each do |doc_data|
Captain::Document.create!(
account: @account,
assistant: assistant,
name: doc_data['name'],
external_link: doc_data['external_link'],
content: doc_data['content'],
status: doc_data['status'] || 'available'
)
end
end
def seed_captain_scenarios(assistant, scenarios_data)
scenarios_data.each do |scenario_data|
Captain::Scenario.create!(
account: @account,
assistant: assistant,
title: scenario_data['title'],
description: scenario_data['description'],
instruction: scenario_data['instruction'],
enabled: scenario_data['enabled'] != false
)
end
end
def finalize_seeding
# Reload account to ensure all associations are fresh
@account.reload
# Reset cache keys for labels, inboxes, teams
@account.reset_cache_keys if @account.respond_to?(:reset_cache_keys)
# Clear any Rails cache related to this account
Rails.cache.delete("account/#{@account.id}")
# Force reload of all account users to refresh their associations
@account.account_users.each(&:reload)
Rails.logger.info("Seed completed for account #{@account.id} - #{@account.name}")
end
end end
+32 -24
View File
@@ -6,7 +6,7 @@ module Seeders::MessageSeeder
conversation: conversation, conversation: conversation,
message_type: :template, message_type: :template,
content_type: :input_email, content_type: :input_email,
content: 'Get notified by email' content: 'Want us to email you a copy of your quote?'
) )
end end
@@ -17,7 +17,7 @@ module Seeders::MessageSeeder
conversation: conversation, conversation: conversation,
message_type: :template, message_type: :template,
content_type: :input_csat, content_type: :input_csat,
content: 'Please rate the support' content: 'How would you rate your experience with Paperlayer today?'
) )
end end
@@ -41,18 +41,18 @@ module Seeders::MessageSeeder
def self.sample_card_item def self.sample_card_item
{ {
media_url: 'https://i.imgur.com/d8Djr4k.jpg', media_url: 'https://i.imgur.com/d8Djr4k.jpg',
title: 'Acme Shoes 2.0', title: 'Premium Copy Paper - 20lb',
description: 'Move with Acme Shoe 2.0', description: 'Bright white, 500 sheets per ream. Perfect for everyday printing.',
actions: [ actions: [
{ {
type: 'link', type: 'link',
text: 'View More', text: 'View Details',
uri: 'http://acme-shoes.inc' uri: 'http://paperlayer.com/products/copy-paper'
}, },
{ {
type: 'postback', type: 'postback',
text: 'Add to cart', text: 'Request Quote',
payload: 'ITEM_SELECTED' payload: 'QUOTE_REQUESTED'
} }
] ]
} }
@@ -64,14 +64,14 @@ module Seeders::MessageSeeder
inbox: conversation.inbox, inbox: conversation.inbox,
conversation: conversation, conversation: conversation,
message_type: :template, message_type: :template,
content: 'Your favorite food', content: 'What type of paper are you interested in?',
content_type: 'input_select', content_type: 'input_select',
content_attributes: { content_attributes: {
items: [ items: [
{ title: '🌯 Burito', value: 'Burito' }, { title: '📄 Copy Paper (20lb)', value: 'copy-paper-20' },
{ title: '🍝 Pasta', value: 'Pasta' }, { title: '📋 Cardstock (110lb)', value: 'cardstock-110' },
{ title: ' 🍱 Sushi', value: 'Sushi' }, { title: '🎨 Colored Paper', value: 'colored-paper' },
{ title: ' 🥗 Salad', value: 'Salad' } { title: '✨ Premium (32lb)', value: 'premium-32' }
] ]
} }
) )
@@ -92,14 +92,20 @@ module Seeders::MessageSeeder
def self.sample_form def self.sample_form
{ {
items: [ items: [
{ name: 'email', placeholder: 'Please enter your email', type: 'email', label: 'Email', required: 'required', { name: 'company', placeholder: 'Your company name', type: 'text', label: 'Company Name', required: 'required',
pattern_error: 'Please fill this field', pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$' }, pattern_error: 'Please enter your company name' },
{ name: 'text_area', placeholder: 'Please enter text', type: 'text_area', label: 'Large Text', required: 'required', { name: 'email', placeholder: 'your.email@company.com', type: 'email', label: 'Email Address', required: 'required',
pattern_error: 'Please fill this field' }, pattern_error: 'Please enter a valid email', pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$' },
{ name: 'text', placeholder: 'Please enter text', type: 'text', label: 'text', default: 'defaut value', required: 'required', { name: 'quantity', placeholder: 'e.g., 50 cases', type: 'text', label: 'Quantity Needed', required: 'required',
pattern: '^[a-zA-Z ]*$', pattern_error: 'Only alphabets are allowed' }, pattern_error: 'Please specify quantity' },
{ name: 'select', label: 'Select Option', type: 'select', options: [{ label: '🌯 Burito', value: 'Burito' }, { name: 'paper_type', label: 'Paper Type', type: 'select', options: [
{ label: '🍝 Pasta', value: 'Pasta' }] } { label: '📄 Copy Paper (20lb)', value: 'copy-20' },
{ label: '📋 Cardstock (110lb)', value: 'cardstock-110' },
{ label: '🎨 Colored Paper', value: 'colored' },
{ label: '✨ Premium (32lb)', value: 'premium-32' }
] },
{ name: 'requirements', placeholder: 'Any special requirements or delivery instructions', type: 'text_area',
label: 'Additional Notes', pattern_error: 'Please fill this field' }
] ]
} }
end end
@@ -110,12 +116,14 @@ module Seeders::MessageSeeder
inbox: conversation.inbox, inbox: conversation.inbox,
conversation: conversation, conversation: conversation,
message_type: :template, message_type: :template,
content: 'Tech Companies', content: 'Helpful Resources',
content_type: 'article', content_type: 'article',
content_attributes: { content_attributes: {
items: [ items: [
{ title: 'Acme Hardware', description: 'Hardware reimagined', link: 'http://acme-hardware.inc' }, { title: 'Paper Weight Guide', description: 'Understanding paper weights and their best uses',
{ title: 'Acme Search', description: 'The best Search Engine', link: 'http://acme-search.inc' } link: 'http://paperlayer.com/help/paper-weights' },
{ title: 'Bulk Order Discounts', description: 'Learn about our volume pricing tiers', link: 'http://paperlayer.com/help/bulk-pricing' },
{ title: 'Custom Printing Options', description: 'Letterhead, envelopes, and branded materials', link: 'http://paperlayer.com/help/custom-printing' }
] ]
} }
) )
+1234 -395
View File
File diff suppressed because it is too large Load Diff
+5 -10
View File
@@ -36,21 +36,16 @@ class Webhooks::Trigger
case @webhook_type case @webhook_type
when :agent_bot_webhook when :agent_bot_webhook
update_conversation_status(message) conversation = message.conversation
return unless conversation&.pending?
conversation.open!
create_agent_bot_error_activity(conversation)
when :api_inbox_webhook when :api_inbox_webhook
update_message_status(error) update_message_status(error)
end end
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) def create_agent_bot_error_activity(conversation)
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open') content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content)) Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
+57 -98
View File
@@ -1,108 +1,67 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="utf-8"> <title>The page you were looking for doesn't exist (404)</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Page not found</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body {
body { background-color: #EFEFEF;
min-height: 100vh; color: #2E2F30;
display: flex; text-align: center;
flex-direction: column; font-family: arial, sans-serif;
align-items: center; margin: 0;
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); div.dialog {
color: rgb(28, 32, 36); width: 95%;
padding: 2rem 1.5rem; max-width: 33em;
-webkit-font-smoothing: antialiased; margin: 4em auto 0;
} }
.page {
display: flex; div.dialog > div {
flex-direction: column; border: 1px solid #CCC;
align-items: center; border-right-color: #999;
width: 100%; border-left-color: #999;
max-width: 28rem; border-bottom-color: #BBB;
} border-top: #B00100 solid 4px;
.error-number { border-top-left-radius: 9px;
font-size: 8rem; border-top-right-radius: 9px;
font-weight: 700; background-color: white;
line-height: 1; padding: 7px 12% 0;
letter-spacing: -0.04em; box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
background: linear-gradient(180deg, rgb(39, 129, 246) 0%, rgb(155, 195, 252) 100%); }
-webkit-background-clip: text;
-webkit-text-fill-color: transparent; h1 {
background-clip: text; font-size: 100%;
margin-bottom: 1.5rem; color: #730E15;
} line-height: 1.5em;
h1 { }
font-size: 1.5rem;
font-weight: 600; div.dialog > p {
line-height: 1.3; margin: 0 0 1em;
text-align: center; padding: 1em;
margin-bottom: 0.5rem; background-color: #F7F7F7;
} border: 1px solid #CCC;
.description { border-right-color: #999;
font-size: 0.9375rem; border-left-color: #999;
color: rgb(96, 100, 108); border-bottom-color: #999;
line-height: 1.6; border-bottom-left-radius: 4px;
text-align: center; border-bottom-right-radius: 4px;
margin-bottom: 2.5rem; border-top-color: #DADADA;
} color: #666;
.btn { box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
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> </style>
</head> </head>
<body> <body>
<div class="page"> <!-- This file lives in public/404.html -->
<div class="error-number">404</div> <div class="dialog">
<h1>Page not found</h1> <div>
<p class="description">The page you're looking for doesn't exist or has been moved.</p> <h1>The page you were looking for doesn't exist.</h1>
<a href="/" class="btn"> <p>You may have mistyped the address or the page may have moved.</p>
<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> </div>
Back to home <p>If you are the application owner check the logs for more information.</p>
</a>
<div class="divider"></div>
<p class="help">If you think this is a mistake, please reach out to support.</p>
</div> </div>
</body> </body>
</html> </html>
+57 -103
View File
@@ -1,113 +1,67 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="utf-8"> <title>The change you wanted was rejected (422)</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Request rejected</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body {
body { background-color: #EFEFEF;
min-height: 100vh; color: #2E2F30;
display: flex; text-align: center;
flex-direction: column; font-family: arial, sans-serif;
align-items: center; margin: 0;
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); div.dialog {
color: rgb(28, 32, 36); width: 95%;
padding: 2rem 1.5rem; max-width: 33em;
-webkit-font-smoothing: antialiased; margin: 4em auto 0;
} }
.page {
display: flex; div.dialog > div {
flex-direction: column; border: 1px solid #CCC;
align-items: center; border-right-color: #999;
width: 100%; border-left-color: #999;
max-width: 28rem; border-bottom-color: #BBB;
} border-top: #B00100 solid 4px;
.error-number { border-top-left-radius: 9px;
font-size: 8rem; border-top-right-radius: 9px;
font-weight: 700; background-color: white;
line-height: 1; padding: 7px 12% 0;
letter-spacing: -0.04em; box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
background: linear-gradient(180deg, rgb(229, 70, 102) 0%, rgb(248, 191, 200) 100%); }
-webkit-background-clip: text;
-webkit-text-fill-color: transparent; h1 {
background-clip: text; font-size: 100%;
margin-bottom: 1.5rem; color: #730E15;
} line-height: 1.5em;
h1 { }
font-size: 1.5rem;
font-weight: 600; div.dialog > p {
line-height: 1.3; margin: 0 0 1em;
text-align: center; padding: 1em;
margin-bottom: 0.5rem; background-color: #F7F7F7;
} border: 1px solid #CCC;
.description { border-right-color: #999;
font-size: 0.9375rem; border-left-color: #999;
color: rgb(96, 100, 108); border-bottom-color: #999;
line-height: 1.6; border-bottom-left-radius: 4px;
text-align: center; border-bottom-right-radius: 4px;
margin-bottom: 2.5rem; border-top-color: #DADADA;
} color: #666;
.btn { box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
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> </style>
</head> </head>
<body> <body>
<div class="page"> <!-- This file lives in public/422.html -->
<div class="error-number">422</div> <div class="dialog">
<h1>Request rejected</h1> <div>
<p class="description">Your request couldn't be processed due to a security verification failure or invalid data.</p> <h1>The change you wanted was rejected.</h1>
<a href="/" class="btn"> <p>Maybe you tried to change something you didn't have access to.</p>
<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> </div>
Back to home <p>If you are the application owner check the logs for more information.</p>
</a>
<div class="divider"></div>
<p class="help">If this keeps happening, try clearing your cookies and refreshing.</p>
</div> </div>
</body> </body>
</html> </html>
+56 -98
View File
@@ -1,108 +1,66 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html>
<head> <head>
<meta charset="utf-8"> <title>We're sorry, but something went wrong (500)</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Something went wrong</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body {
body { background-color: #EFEFEF;
min-height: 100vh; color: #2E2F30;
display: flex; text-align: center;
flex-direction: column; font-family: arial, sans-serif;
align-items: center; margin: 0;
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); div.dialog {
color: rgb(28, 32, 36); width: 95%;
padding: 2rem 1.5rem; max-width: 33em;
-webkit-font-smoothing: antialiased; margin: 4em auto 0;
} }
.page {
display: flex; div.dialog > div {
flex-direction: column; border: 1px solid #CCC;
align-items: center; border-right-color: #999;
width: 100%; border-left-color: #999;
max-width: 28rem; border-bottom-color: #BBB;
} border-top: #B00100 solid 4px;
.error-number { border-top-left-radius: 9px;
font-size: 8rem; border-top-right-radius: 9px;
font-weight: 700; background-color: white;
line-height: 1; padding: 7px 12% 0;
letter-spacing: -0.04em; box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
background: linear-gradient(180deg, rgb(207, 74, 34) 0%, rgb(247, 164, 120) 100%); }
-webkit-background-clip: text;
-webkit-text-fill-color: transparent; h1 {
background-clip: text; font-size: 100%;
margin-bottom: 1.5rem; color: #730E15;
} line-height: 1.5em;
h1 { }
font-size: 1.5rem;
font-weight: 600; div.dialog > p {
line-height: 1.3; margin: 0 0 1em;
text-align: center; padding: 1em;
margin-bottom: 0.5rem; background-color: #F7F7F7;
} border: 1px solid #CCC;
.description { border-right-color: #999;
font-size: 0.9375rem; border-left-color: #999;
color: rgb(96, 100, 108); border-bottom-color: #999;
line-height: 1.6; border-bottom-left-radius: 4px;
text-align: center; border-bottom-right-radius: 4px;
margin-bottom: 2.5rem; border-top-color: #DADADA;
} color: #666;
.btn { box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
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> </style>
</head> </head>
<body> <body>
<div class="page"> <!-- This file lives in public/500.html -->
<div class="error-number">500</div> <div class="dialog">
<h1>Something went wrong</h1> <div>
<p class="description">An unexpected error occurred. Please refresh the page or try again shortly.</p> <h1>We're sorry, but something went wrong.</h1>
<a href="/" class="btn"> </div>
<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> <p>If you are the application owner check the logs for more information.</p>
Back to home
</a>
<div class="divider"></div>
<p class="help">If the problem persists, please try again in a few minutes.</p>
</div> </div>
</body> </body>
</html> </html>
@@ -144,13 +144,13 @@ RSpec.describe Concerns::Agentable do
it 'returns default model when config not found' do it 'returns default model when config not found' do
allow(InstallationConfig).to receive(:find_by).and_return(nil) allow(InstallationConfig).to receive(:find_by).and_return(nil)
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1') expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini')
end end
it 'returns default model when config value is nil' do it 'returns default model when config value is nil' do
allow(mock_installation_config).to receive(:value).and_return(nil) allow(mock_installation_config).to receive(:value).and_return(nil)
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1') expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini')
end end
end end
@@ -16,9 +16,8 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
# Link inbox to assignment policy # Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
# Enable assignment_v2 (base) and advanced_assignment (premium) features allow(account).to receive(:feature_enabled?).and_return(false)
account.enable_features('assignment_v2') allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
account.save!
# Set agents as online # Set agents as online
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id) OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
@@ -52,9 +52,8 @@ RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
agent_at_capacity.id.to_s => 'online' agent_at_capacity.id.to_s => 'online'
}) })
# Enable assignment_v2 (base) and advanced_assignment (premium) features # Enable assignment_v2 feature
account.enable_features('assignment_v2', 'advanced_assignment') allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
account.save!
# Create existing assignments for agent_at_capacity (at limit) # Create existing assignments for agent_at_capacity (at limit)
3.times do 3.times do
@@ -238,77 +238,6 @@ RSpec.describe MessageTemplates::HookExecutionService do
end end
end end
context 'when conversation has a campaign' do
let(:campaign) { create(:campaign, account: account) }
let(:campaign_conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending, campaign: campaign) }
it 'schedules captain response job for incoming messages on pending campaign conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(campaign_conversation, assistant)
create(:message, conversation: campaign_conversation, message_type: :incoming)
end
it 'does not send greeting template on campaign conversations' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
greeting_service = instance_double(MessageTemplates::Template::Greeting)
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
create(:message, conversation: campaign_conversation, message_type: :incoming)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
end
it 'does not send out of office template on campaign conversations' do
inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently closed')
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: campaign_conversation, message_type: :incoming)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
it 'does not send email collect template on campaign conversations' do
contact.update!(email: nil)
inbox.update!(enable_email_collect: true)
email_collect_service = instance_double(MessageTemplates::Template::EmailCollect)
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
create(:message, conversation: campaign_conversation, message_type: :incoming)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
end
it 'does not send out of office template after handoff on campaign conversations when quota is exceeded' do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed'
)
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
closed_all_day: true,
open_all_day: false
)
expect do
create(:message, conversation: campaign_conversation, message_type: :incoming)
end.not_to(change { campaign_conversation.messages.template.count })
end
end
context 'when Captain quota is exceeded and handoff happens' do context 'when Captain quota is exceeded and handoff happens' do
before do before do
account.update!( account.update!(
@@ -14,13 +14,13 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
describe '#perform' do describe '#perform' do
context 'when account has assignment_v2 feature enabled' do context 'when account has assignment_v2 feature enabled' do
before do before do
account.enable_features('assignment_v2') allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
account.save!
allow(Account).to receive(:find_in_batches).and_yield([account]) allow(Account).to receive(:find_in_batches).and_yield([account])
end end
context 'when inbox has assignment policy or auto assignment enabled' do context 'when inbox has auto_assignment_v2 enabled' do
before do before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation) inbox_relation = instance_double(ActiveRecord::Relation)
allow(account).to receive(:inboxes).and_return(inbox_relation) allow(account).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).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) policy2 = create(:assignment_policy, account: account2)
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2) create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
account2.enable_features('assignment_v2') allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
account2.save! allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation2 = instance_double(ActiveRecord::Relation) inbox_relation2 = instance_double(ActiveRecord::Relation)
allow(account2).to receive(:inboxes).and_return(inbox_relation2) allow(account2).to receive(:inboxes).and_return(inbox_relation2)
@@ -58,10 +58,9 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end end
end end
context 'when inbox does not have assignment policy or auto assignment enabled' do context 'when inbox does not have auto_assignment_v2 enabled' do
before do before do
inbox.update!(enable_auto_assignment: false) allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
InboxAssignmentPolicy.where(inbox: inbox).destroy_all
end end
it 'does not queue assignment job' do it 'does not queue assignment job' do
@@ -74,6 +73,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
context 'when account does not have assignment_v2 feature enabled' do context 'when account does not have assignment_v2 feature enabled' do
before 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]) allow(Account).to receive(:find_in_batches).and_yield([account])
end end
@@ -90,11 +90,11 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
# Create multiple accounts # Create multiple accounts
5.times do |_i| 5.times do |_i|
acc = create(:account) acc = create(:account)
acc.enable_features('assignment_v2')
acc.save!
inb = create(:inbox, account: acc, enable_auto_assignment: true) inb = create(:inbox, account: acc, enable_auto_assignment: true)
policy = create(:assignment_policy, account: acc) policy = create(:assignment_policy, account: acc)
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy) 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) inbox_relation = instance_double(ActiveRecord::Relation)
allow(acc).to receive(:inboxes).and_return(inbox_relation) allow(acc).to receive(:inboxes).and_return(inbox_relation)
-48
View File
@@ -93,30 +93,6 @@ describe Linear do
end end
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') } let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
context 'when description contains double quotes' do
it 'produces valid GraphQL by escaping the quotes' do
allow(linear_client).to receive(:post) do |payload|
expect(payload[:query]).to include('description: "the sender is \\"Bot\\"')
instance_double(HTTParty::Response, success?: true,
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
end
linear_client.create_issue(params.merge(description: 'the sender is "Bot"'))
end
end
context 'when description contains backslashes' do
it 'produces valid GraphQL by escaping the backslashes' do
allow(linear_client).to receive(:post) do |payload|
expect(payload[:query]).to include('description: "path\\\\to\\\\file"')
instance_double(HTTParty::Response, success?: true,
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
end
linear_client.create_issue(params.merge(description: 'path\\to\\file'))
end
end
context 'when the API response is success' do context 'when the API response is success' do
before do before do
stub_request(:post, url) stub_request(:post, url)
@@ -237,18 +213,6 @@ describe Linear do
let(:title) { 'Title' } let(:title) { 'Title' }
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') } let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
context 'when title contains double quotes' do
it 'produces valid GraphQL by escaping the quotes' do
allow(linear_client).to receive(:post) do |payload|
expect(payload[:query]).to include('title: "say \\"hello\\"')
instance_double(HTTParty::Response, success?: true,
parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
end
linear_client.link_issue(link, issue_id, 'say "hello"')
end
end
context 'when the API response is success' do context 'when the API response is success' do
before do before do
stub_request(:post, url) stub_request(:post, url)
@@ -368,18 +332,6 @@ describe Linear do
context 'when querying issues' do context 'when querying issues' do
let(:term) { 'term' } let(:term) { 'term' }
context 'when search term contains double quotes' do
it 'produces valid GraphQL by escaping the quotes' do
allow(linear_client).to receive(:post) do |payload|
expect(payload[:query]).to include('term: "find \\"Bot\\"')
instance_double(HTTParty::Response, success?: true,
parsed_response: { 'data' => { 'searchIssues' => { 'nodes' => [] } } })
end
linear_client.search_issue('find "Bot"')
end
end
context 'when the API response is success' do context 'when the API response is success' do
before do before do
stub_request(:post, url) stub_request(:post, url)
+6 -50
View File
@@ -74,11 +74,10 @@ describe Webhooks::Trigger do
context 'when webhook type is agent bot' do context 'when webhook type is agent bot' do
let(:webhook_type) { :agent_bot_webhook } 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 it 'reopens conversation and enqueues activity message if pending' do
payload = { event: 'message_created', id: pending_message.id } conversation.update(status: :pending)
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(RestClient::Request).to receive(:execute) expect(RestClient::Request).to receive(:execute)
.with( .with(
@@ -93,11 +92,11 @@ describe Webhooks::Trigger do
perform_enqueued_jobs do perform_enqueued_jobs do
trigger.execute(url, payload, webhook_type) trigger.execute(url, payload, webhook_type)
end end
end.not_to(change { pending_message.reload.status }) end.not_to(change { message.reload.status })
expect(pending_conversation.reload.status).to eq('open') expect(conversation.reload.status).to eq('open')
activity_message = pending_conversation.reload.messages.order(:created_at).last activity_message = conversation.reload.messages.order(:created_at).last
expect(activity_message.message_type).to eq('activity') expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to eq(agent_bot_error_content) expect(activity_message.content).to eq(agent_bot_error_content)
end end
@@ -119,52 +118,9 @@ describe Webhooks::Trigger do
end.not_to(change { message.reload.status }) end.not_to(change { message.reload.status })
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
expect(conversation.reload.status).to eq('open') expect(conversation.reload.status).to eq('open')
end 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
end end
-80
View File
@@ -178,85 +178,5 @@ RSpec.describe MailPresenter do
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
end end
end end
describe 'malformed sender headers' do
let(:mail_with_malformed_from) do
Mail.new do
header['From'] = 'Kevin McDonald <info@example.com'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
end
end
let(:mail_with_malformed_reply_to) do
Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['Reply-To'] = 'Reply User <reply@example.com'
end
end
let(:mail_with_original_sender_header) do
Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['Reply-To'] = 'Reply User <reply@example.com'
header['X-Original-Sender'] = 'Forwarded Sender <forwarded.sender@example.com>'
end
end
let(:mail_with_invalid_original_sender_header) do
Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['Reply-To'] = 'Reply User <reply@example.com'
header['X-Original-Sender'] = 'not an email address'
end
end
it 'returns nil sender values when from header is malformed' do
presenter = described_class.new(mail_with_malformed_from)
expect(presenter.original_sender).to be_nil
expect(presenter.sender_name).to be_nil
expect(presenter.notification_email_from_chatwoot?).to be(false)
end
it 'falls back to from header when reply_to is malformed' do
presenter = described_class.new(mail_with_malformed_reply_to)
expect(presenter.original_sender).to eq('sender@example.com')
end
it 'uses parsed X-Original-Sender value when available' do
presenter = described_class.new(mail_with_original_sender_header)
expect(presenter.original_sender).to eq('forwarded.sender@example.com')
end
it 'falls back to from when X-Original-Sender is invalid' do
presenter = described_class.new(mail_with_invalid_original_sender_header)
expect(presenter.original_sender).to eq('sender@example.com')
end
it 'matches notification sender emails case-insensitively' do
mail_with_uppercase_sender = Mail.new do
from 'Chatwoot <ACCOUNTS@CHATWOOT.COM>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
end
with_modified_env MAILER_SENDER_EMAIL: 'Chatwoot <accounts@chatwoot.com>' do
presenter = described_class.new(mail_with_uppercase_sender)
expect(presenter.notification_email_from_chatwoot?).to be(true)
end
end
end
end end
end end
@@ -10,9 +10,8 @@ RSpec.describe AutoAssignment::AssignmentService do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) } let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do before do
# Enable assignment_v2 feature for the account (basic assignment features) # Enable assignment_v2 feature for the account
account.enable_features('assignment_v2') allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
account.save!
# Link inbox to assignment policy # Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
create(:inbox_member, inbox: inbox, user: agent) create(:inbox_member, inbox: inbox, user: agent)
@@ -59,9 +59,8 @@ RSpec.describe AutoAssignment::RateLimiter do
allow(inbox).to receive(:assignment_policy).and_return(nil) allow(inbox).to receive(:assignment_policy).and_return(nil)
end end
it 'still tracks the assignment with default window' do it 'does not track the assignment' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id) expect(Redis::Alfred).not_to receive(:set)
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
rate_limiter.track_assignment(conversation) rate_limiter.track_assignment(conversation)
end end
end end
@@ -111,40 +111,6 @@ describe MessageTemplates::HookExecutionService do
end end
end end
context 'when conversation has a campaign' do
let(:campaign) { create(:campaign) }
it 'does not call ::MessageTemplates::Template::Greeting on campaign conversations' do
contact = create(:contact, email: nil)
conversation = create(:conversation, contact: contact, campaign: campaign)
conversation.inbox.update(greeting_enabled: true, greeting_message: 'Hi, this is a greeting message', enable_email_collect: false)
greeting_service = double
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
end
it 'does not call ::MessageTemplates::Template::OutOfOffice on campaign conversations' do
contact = create(:contact)
conversation = create(:conversation, contact: contact, campaign: campaign)
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
conversation.inbox.working_hours.today.update!(closed_all_day: true)
out_of_office_service = double
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when message is an auto reply email' do context 'when message is an auto reply email' do
it 'does not call any template hooks' do it 'does not call any template hooks' do
contact = create(:contact) contact = create(:contact)