Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
859ab6777c |
@@ -94,7 +94,6 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
.codex/
|
||||
CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
@@ -102,4 +101,3 @@ CLAUDE.local.md
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
Procfile.worktree
|
||||
|
||||
@@ -50,13 +50,6 @@
|
||||
- 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
|
||||
|
||||
## 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
|
||||
|
||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||
|
||||
@@ -197,8 +197,6 @@ gem 'ai-agents', '>= 0.7.0'
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
gem 'cld3', '~> 3.7'
|
||||
|
||||
# OpenTelemetry for LLM observability
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
+7
-9
@@ -186,7 +186,6 @@ GEM
|
||||
byebug (11.1.3)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
cld3 (3.7.0)
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
@@ -298,7 +297,7 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
faker (3.2.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -309,8 +308,8 @@ GEM
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
@@ -465,7 +464,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.18.1)
|
||||
json (2.13.2)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -563,8 +562,8 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.20)
|
||||
@@ -969,7 +968,7 @@ GEM
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.1.1)
|
||||
uri (1.0.4)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1038,7 +1037,6 @@ DEPENDENCIES
|
||||
bullet
|
||||
bundle-audit
|
||||
byebug
|
||||
cld3 (~> 3.7)
|
||||
climate_control
|
||||
commonmarker
|
||||
csv-safe
|
||||
|
||||
@@ -70,7 +70,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def transcript
|
||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
|
||||
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
||||
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||
|
||||
@@ -35,9 +35,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def transcript
|
||||
return head :too_many_requests if conversation.blank?
|
||||
return head :payment_required unless conversation.account.email_transcript_enabled?
|
||||
return head :too_many_requests unless conversation.account.within_email_rate_limit?
|
||||
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
|
||||
|
||||
send_transcript_email
|
||||
head :ok
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ const policyA = withCount({
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -49,6 +50,7 @@ const policyB = withCount({
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
+17
@@ -15,6 +15,7 @@ const props = defineProps({
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
@@ -64,6 +65,22 @@ const handleFetchInboxes = () => {
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
|
||||
@@ -19,15 +19,11 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete', 'navigate']);
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
|
||||
const handleNavigate = item => {
|
||||
emit('navigate', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,11 +47,7 @@ const handleNavigate = item => {
|
||||
: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"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
|
||||
@click="handleNavigate(item)"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
@@ -69,16 +61,10 @@ const handleNavigate = item => {
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-external-link"
|
||||
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
|
||||
+4
-18
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
@@ -15,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', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
@@ -28,17 +25,6 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
// Convert seconds to minutes for DurationInput
|
||||
const windowInMinutes = computed({
|
||||
get() {
|
||||
return Math.floor((fairDistributionWindow.value || 0) / 60);
|
||||
},
|
||||
set(minutes) {
|
||||
fairDistributionWindow.value = minutes * 60;
|
||||
},
|
||||
});
|
||||
|
||||
// Detect unit based on minutes (converted from seconds)
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
@@ -48,7 +34,7 @@ const detectUnit = minutes => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -87,9 +73,9 @@ onMounted(() => {
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model:model-value="windowInMinutes"
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
@@ -18,22 +16,12 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabledMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive && !props.disabled) {
|
||||
if (!props.isActive) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
@@ -41,11 +29,9 @@ const handleChange = () => {
|
||||
|
||||
<template>
|
||||
<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="[
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
@@ -55,7 +41,6 @@ const handleChange = () => {
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
@@ -64,23 +49,11 @@ const handleChange = () => {
|
||||
|
||||
<!-- Content -->
|
||||
<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">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="disabled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
@@ -6,6 +6,7 @@ const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,10 +19,13 @@ const description = ref(
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -51,18 +50,6 @@ const isRTL = useMapGetter('accounts/isRTL');
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -597,16 +584,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-users',
|
||||
to: accountScopedRoute('settings_teams_list'),
|
||||
},
|
||||
...(hasAdvancedAssignment.value
|
||||
? [
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Inboxes',
|
||||
label: t('SIDEBAR.INBOXES'),
|
||||
|
||||
@@ -31,10 +31,7 @@ const assignedAgent = computed({
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: currentChat.value?.id,
|
||||
assignee: agent,
|
||||
});
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
|
||||
@@ -2,7 +2,6 @@ export const FEATURE_FLAGS = {
|
||||
AGENT_BOTS: 'agent_bots',
|
||||
AGENT_MANAGEMENT: 'agent_management',
|
||||
ASSIGNMENT_V2: 'assignment_v2',
|
||||
ADVANCED_ASSIGNMENT: 'advanced_assignment',
|
||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
@@ -57,5 +56,4 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.SAML,
|
||||
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_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
|
||||
},
|
||||
"ASSIGNMENT": {
|
||||
"TITLE": "Conversation Assignment",
|
||||
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
|
||||
"DEFAULT_RULES_TITLE": "Default assignment rules",
|
||||
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
|
||||
"DEFAULT_RULE_1": "Earliest created conversations first",
|
||||
"DEFAULT_RULE_2": "Round robin distribution",
|
||||
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||
"USING_POLICY": "Using custom assignment policy for this inbox",
|
||||
"CUSTOMIZE_POLICY": "Customize with assignment policy",
|
||||
"DELETE_POLICY": "Delete policy",
|
||||
"POLICY_LABEL": "Assignment policy",
|
||||
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
|
||||
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
|
||||
"POLICY_STATUS": {
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"EARLIEST_CREATED": "Earliest created",
|
||||
"LONGEST_WAITING": "Longest waiting"
|
||||
},
|
||||
"METHOD": {
|
||||
"ROUND_ROBIN": "Round robin",
|
||||
"BALANCED": "Balanced assignment"
|
||||
},
|
||||
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
|
||||
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
|
||||
"DEFAULT_POLICY_LINKED": "Default policy linked",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
|
||||
"LINK_EXISTING_POLICY": "Link existing policy",
|
||||
"CREATE_NEW_POLICY": "Create new policy",
|
||||
"NO_POLICIES": "No assignment policies found",
|
||||
"VIEW_ALL_POLICIES": "View all policies",
|
||||
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
|
||||
"LINK_SUCCESS": "Assignment policy linked successfully",
|
||||
"LINK_ERROR": "Failed to link assignment policy"
|
||||
},
|
||||
"ASSIGNMENT_POLICY": {
|
||||
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
|
||||
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM_DELETE": "Delete",
|
||||
"DELETE_SUCCESS": "Assignment policy removed successfully",
|
||||
"DELETE_ERROR": "Failed to remove assignment policy"
|
||||
},
|
||||
"FACEBOOK_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize",
|
||||
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
|
||||
|
||||
@@ -694,8 +694,7 @@
|
||||
"CREATE_BUTTON": "Create policy",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy",
|
||||
"INBOX_LINKED": "Inbox has been linked to the policy"
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -709,12 +708,6 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Continue",
|
||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
||||
},
|
||||
"INBOX_LINK_PROMPT": {
|
||||
"TITLE": "Link inbox to policy",
|
||||
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
|
||||
"LINK_BUTTON": "Link inbox",
|
||||
"CANCEL_BUTTON": "Skip"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update assignment policy"
|
||||
@@ -753,9 +746,7 @@
|
||||
},
|
||||
"BALANCED": {
|
||||
"LABEL": "Balanced",
|
||||
"DESCRIPTION": "Assign conversations based on available capacity.",
|
||||
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
|
||||
"PREMIUM_BADGE": "Premium"
|
||||
"DESCRIPTION": "Assign conversations based on available capacity."
|
||||
}
|
||||
},
|
||||
"ASSIGNMENT_PRIORITY": {
|
||||
@@ -841,20 +832,6 @@
|
||||
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
|
||||
"ERROR_MESSAGE": "Failed to remove agent from policy"
|
||||
}
|
||||
},
|
||||
"INBOX_LIMIT_API": {
|
||||
"ADD": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit added successfully",
|
||||
"ERROR_MESSAGE": "Failed to add inbox limit"
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update inbox limit"
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete inbox limit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
@@ -85,10 +85,7 @@ export default {
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : null;
|
||||
this.$store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: this.currentChat.id,
|
||||
assignee: agent,
|
||||
});
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
|
||||
@@ -1,81 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = computed(() => Number(route.params.accountId));
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const agentAssignments = computed(() => {
|
||||
const assignments = [
|
||||
{
|
||||
key: 'agent_assignment_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
|
||||
// advanced_assignment identifies premium users
|
||||
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'assignment_v2'
|
||||
);
|
||||
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'advanced_assignment'
|
||||
);
|
||||
|
||||
if (hasAssignmentV2 && hasAdvancedAssignment) {
|
||||
assignments.push({
|
||||
key: 'agent_capacity_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||
description: t(
|
||||
'ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'
|
||||
),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return assignments;
|
||||
});
|
||||
const agentAssignments = computed(() => [
|
||||
{
|
||||
key: 'agent_assignment_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-circle-fading-arrow-up',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-inbox',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'agent_capacity_policy_index',
|
||||
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
|
||||
description: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.DESCRIPTION'),
|
||||
features: [
|
||||
{
|
||||
icon: 'i-lucide-glass-water',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.0'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-circle-minus',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.1'),
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-users-round',
|
||||
label: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.FEATURES.2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const handleClick = key => {
|
||||
router.push({ name: key });
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ export default {
|
||||
name: 'agent_capacity_policy_index',
|
||||
component: AgentCapacityIndex,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
name: 'agent_capacity_policy_create',
|
||||
component: AgentCapacityCreate,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@ export default {
|
||||
name: 'agent_capacity_policy_edit',
|
||||
component: AgentCapacityEdit,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
+13
-46
@@ -2,14 +2,13 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
@@ -17,50 +16,20 @@ const { t } = useI18n();
|
||||
const formRef = ref(null);
|
||||
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
|
||||
|
||||
const inboxIdFromQuery = computed(() => {
|
||||
const id = route.query.inboxId;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (inboxIdFromQuery.value) {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_MGMT.SETTINGS'),
|
||||
routeName: 'settings_inbox_show',
|
||||
params: { inboxId: inboxIdFromQuery.value },
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
];
|
||||
});
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{
|
||||
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
|
||||
},
|
||||
]);
|
||||
|
||||
const handleBreadcrumbClick = item => {
|
||||
if (item.params) {
|
||||
const accountId = route.params.accountId;
|
||||
const inboxId = item.params.inboxId;
|
||||
// Navigate using explicit path to ensure tab parameter is included
|
||||
router.push(
|
||||
`/app/accounts/${accountId}/settings/inboxes/${inboxId}/collaborators`
|
||||
);
|
||||
} else {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
}
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
@@ -76,8 +45,6 @@ const handleSubmit = async formState => {
|
||||
params: {
|
||||
id: policy.id,
|
||||
},
|
||||
// Pass inboxId to edit page to show link prompt
|
||||
query: inboxIdFromQuery.value ? { inboxId: inboxIdFromQuery.value } : {},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
|
||||
+17
-110
@@ -14,7 +14,6 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
|
||||
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
|
||||
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
|
||||
import InboxLinkDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/InboxLinkDialog.vue';
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||
|
||||
@@ -37,46 +36,13 @@ const confirmInboxDialogRef = ref(null);
|
||||
// Store the policy linked to the inbox when adding a new inbox
|
||||
const inboxLinkedPolicy = ref(null);
|
||||
|
||||
// Inbox linking prompt from create flow
|
||||
const inboxIdFromQuery = computed(() => {
|
||||
const id = route.query.inboxId;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
const suggestedInbox = computed(() => {
|
||||
if (!inboxIdFromQuery.value || !inboxes.value) return null;
|
||||
return inboxes.value.find(inbox => inbox.id === inboxIdFromQuery.value);
|
||||
});
|
||||
|
||||
const isLinkingInbox = ref(false);
|
||||
|
||||
const dismissInboxLinkPrompt = () => {
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
};
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (inboxIdFromQuery.value) {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_MGMT.SETTINGS'),
|
||||
routeName: 'settings_inbox_show',
|
||||
params: { inboxId: inboxIdFromQuery.value },
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
];
|
||||
});
|
||||
const breadcrumbItems = computed(() => [
|
||||
{
|
||||
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
|
||||
routeName: 'agent_assignment_policy_index',
|
||||
},
|
||||
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
|
||||
]);
|
||||
|
||||
const buildInboxList = allInboxes =>
|
||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
@@ -100,48 +66,22 @@ const inboxList = computed(() =>
|
||||
const formData = computed(() => ({
|
||||
name: selectedPolicy.value?.name || '',
|
||||
description: selectedPolicy.value?.description || '',
|
||||
enabled: true,
|
||||
enabled: selectedPolicy.value?.enabled || false,
|
||||
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
|
||||
conversationPriority:
|
||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
|
||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
|
||||
}));
|
||||
|
||||
const handleDeleteInbox = async inboxId => {
|
||||
try {
|
||||
await store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||
policyId: selectedPolicy.value?.id,
|
||||
inboxId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_API.REMOVE.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = ({ routeName, 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 handleDeleteInbox = inboxId =>
|
||||
store.dispatch('assignmentPolicies/removeInboxPolicy', {
|
||||
policyId: selectedPolicy.value?.id,
|
||||
inboxId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
|
||||
const setInboxPolicy = async (inboxId, policyId) => {
|
||||
try {
|
||||
@@ -182,26 +122,6 @@ const handleAddInbox = async inbox => {
|
||||
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
|
||||
};
|
||||
|
||||
const handleLinkSuggestedInbox = async () => {
|
||||
if (!suggestedInbox.value) return;
|
||||
|
||||
isLinkingInbox.value = true;
|
||||
const inbox = {
|
||||
id: suggestedInbox.value.id,
|
||||
name: suggestedInbox.value.name,
|
||||
};
|
||||
|
||||
await handleAddInbox(inbox);
|
||||
|
||||
// Clear the query param after linking
|
||||
router.replace({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
isLinkingInbox.value = false;
|
||||
};
|
||||
|
||||
const handleConfirmAddInbox = async inboxId => {
|
||||
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
|
||||
|
||||
@@ -235,11 +155,6 @@ const handleSubmit = async formState => {
|
||||
const fetchPolicyData = async () => {
|
||||
if (!routeId.value) return;
|
||||
|
||||
// Fetch inboxes if not already loaded (needed for inbox link prompt)
|
||||
if (!inboxes.value?.length) {
|
||||
store.dispatch('inboxes/get');
|
||||
}
|
||||
|
||||
// Fetch policy if not available
|
||||
if (!selectedPolicy.value?.id)
|
||||
await store.dispatch('assignmentPolicies/show', routeId.value);
|
||||
@@ -271,7 +186,6 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
@submit="handleSubmit"
|
||||
@add-inbox="handleAddInbox"
|
||||
@delete-inbox="handleDeleteInbox"
|
||||
@navigate-to-inbox="handleNavigateToInbox"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -279,12 +193,5 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
ref="confirmInboxDialogRef"
|
||||
@add="handleConfirmAddInbox"
|
||||
/>
|
||||
|
||||
<InboxLinkDialog
|
||||
:inbox="suggestedInbox"
|
||||
:is-linking="isLinkingInbox"
|
||||
@link="handleLinkSuggestedInbox"
|
||||
@dismiss="dismissInboxLinkPrompt"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+29
-54
@@ -92,68 +92,43 @@ const formData = computed(() => ({
|
||||
const handleBreadcrumbClick = ({ routeName }) =>
|
||||
router.push({ name: routeName });
|
||||
|
||||
const handleDeleteUser = async agentId => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.REMOVE.ERROR_MESSAGE`));
|
||||
}
|
||||
const handleDeleteUser = agentId => {
|
||||
store.dispatch('agentCapacityPolicies/removeUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userId: agentId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddUser = async agent => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.AGENT_API.ADD.ERROR_MESSAGE`));
|
||||
}
|
||||
const handleAddUser = agent => {
|
||||
store.dispatch('agentCapacityPolicies/addUser', {
|
||||
policyId: selectedPolicyId.value,
|
||||
userData: { id: agent.id, capacity: 20 },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteInboxLimit = async limitId => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.DELETE.ERROR_MESSAGE`));
|
||||
}
|
||||
const handleDeleteInboxLimit = limitId => {
|
||||
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddInboxLimit = async limit => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.ADD.ERROR_MESSAGE`));
|
||||
}
|
||||
const handleAddInboxLimit = limit => {
|
||||
store.dispatch('agentCapacityPolicies/createInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitData: {
|
||||
inboxId: limit.inboxId,
|
||||
conversationLimit: limit.conversationLimit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLimitChange = async limit => {
|
||||
try {
|
||||
await store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.SUCCESS_MESSAGE`));
|
||||
} catch {
|
||||
useAlert(t(`${BASE_KEY}.EDIT.INBOX_LIMIT_API.UPDATE.ERROR_MESSAGE`));
|
||||
}
|
||||
const handleLimitChange = limit => {
|
||||
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
|
||||
policyId: selectedPolicyId.value,
|
||||
limitId: limit.id,
|
||||
limitData: { conversationLimit: limit.conversationLimit },
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async formState => {
|
||||
|
||||
+16
-42
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
@@ -24,6 +23,7 @@ const props = defineProps({
|
||||
default: () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -61,24 +61,18 @@ const emit = defineEmits([
|
||||
'submit',
|
||||
'addInbox',
|
||||
'deleteInbox',
|
||||
'navigateToInbox',
|
||||
'validationChange',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const accountId = computed(() => Number(route.params.accountId));
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -89,42 +83,20 @@ const validationState = ref({
|
||||
isValid: false,
|
||||
});
|
||||
|
||||
const createOption = (
|
||||
type,
|
||||
key,
|
||||
stateKey,
|
||||
disabled = false,
|
||||
disabledMessage = ''
|
||||
) => ({
|
||||
const createOption = (type, key, stateKey) => ({
|
||||
key,
|
||||
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
|
||||
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
|
||||
isActive: state[stateKey] === key,
|
||||
disabled,
|
||||
disabledMessage,
|
||||
});
|
||||
|
||||
const assignmentOrderOptions = computed(() => {
|
||||
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
'advanced_assignment'
|
||||
const options = OPTIONS.ORDER.filter(
|
||||
key => isEnterprise || key !== 'balanced'
|
||||
);
|
||||
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(() =>
|
||||
@@ -159,7 +131,7 @@ const resetForm = () => {
|
||||
Object.assign(state, {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
assignmentOrder: ROUND_ROBIN,
|
||||
conversationPriority: EARLIEST_CREATED,
|
||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||
@@ -190,10 +162,15 @@ defineExpose({
|
||||
<BaseInfo
|
||||
v-model:policy-name="state.name"
|
||||
v-model:description="state.description"
|
||||
v-model:enabled="state.enabled"
|
||||
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
|
||||
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
|
||||
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
|
||||
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
|
||||
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
|
||||
:status-placeholder="
|
||||
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
|
||||
"
|
||||
@validation-change="handleValidationChange"
|
||||
/>
|
||||
|
||||
@@ -216,8 +193,6 @@ defineExpose({
|
||||
:label="option.label"
|
||||
:description="option.description"
|
||||
:is-active="option.isActive"
|
||||
:disabled="option.disabled"
|
||||
:disabled-message="option.disabledMessage"
|
||||
@select="state[section.key] = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -276,7 +251,6 @@ defineExpose({
|
||||
:is-fetching="isInboxLoading"
|
||||
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
|
||||
@delete="$emit('deleteInbox', $event)"
|
||||
@navigate="$emit('navigateToInbox', $event)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
-116
@@ -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>
|
||||
+140
-619
@@ -1,321 +1,122 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { minValue } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
NextButton,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const selectedAgents = ref([]);
|
||||
const isAgentListUpdating = ref(false);
|
||||
const enableAutoAssignment = ref(false);
|
||||
const maxAssignmentLimit = ref(null);
|
||||
const assignmentPolicy = ref(null);
|
||||
const isLoadingPolicy = ref(false);
|
||||
const isDeletingPolicy = ref(false);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const availablePolicies = ref([]);
|
||||
const isLoadingPolicies = ref(false);
|
||||
const showPolicyDropdown = ref(false);
|
||||
const isLinkingPolicy = ref(false);
|
||||
|
||||
const agentList = computed(() => store.getters['agents/getAgents']);
|
||||
|
||||
const isFeatureEnabled = feature => {
|
||||
const accountId = Number(route.params.accountId);
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
feature
|
||||
);
|
||||
};
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabled('advanced_assignment');
|
||||
});
|
||||
|
||||
const hasAssignmentV2 = computed(() => {
|
||||
return isFeatureEnabled('assignment_v2');
|
||||
});
|
||||
|
||||
const showAdvancedAssignmentUI = computed(() => {
|
||||
return hasAdvancedAssignment.value && hasAssignmentV2.value;
|
||||
});
|
||||
|
||||
const assignmentOrderLabel = computed(() => {
|
||||
if (!assignmentPolicy.value) return '';
|
||||
const priority = assignmentPolicy.value.conversation_priority;
|
||||
if (priority === 'earliest_created') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
|
||||
}
|
||||
if (priority === 'longest_waiting') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
|
||||
}
|
||||
return priority;
|
||||
});
|
||||
|
||||
const assignmentMethodLabel = computed(() => {
|
||||
if (!assignmentPolicy.value) return '';
|
||||
const order = assignmentPolicy.value.assignment_order;
|
||||
if (order === 'round_robin') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
|
||||
}
|
||||
if (order === 'balanced') {
|
||||
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
|
||||
}
|
||||
return order;
|
||||
});
|
||||
|
||||
// Vuelidate validation rules
|
||||
const rules = {
|
||||
maxAssignmentLimit: {
|
||||
minValue: minValue(1),
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
setup() {
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const v$ = useVuelidate(rules, { maxAssignmentLimit });
|
||||
|
||||
const maxAssignmentLimitErrors = computed(() => {
|
||||
if (v$.value.maxAssignmentLimit.$error) {
|
||||
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const fetchAttachedAgents = async () => {
|
||||
try {
|
||||
const response = await store.dispatch('inboxMembers/get', {
|
||||
inboxId: props.inbox.id,
|
||||
});
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
selectedAgents.value = inboxMembers;
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAssignmentPolicy = async () => {
|
||||
if (!props.inbox.id) return;
|
||||
|
||||
isLoadingPolicy.value = true;
|
||||
try {
|
||||
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
|
||||
assignmentPolicy.value = response.data;
|
||||
} catch (error) {
|
||||
// No policy attached, which is fine
|
||||
assignmentPolicy.value = null;
|
||||
} finally {
|
||||
isLoadingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailablePolicies = async () => {
|
||||
isLoadingPolicies.value = true;
|
||||
try {
|
||||
const response = await assignmentPoliciesAPI.get();
|
||||
availablePolicies.value = response.data;
|
||||
} catch (error) {
|
||||
availablePolicies.value = [];
|
||||
} finally {
|
||||
isLoadingPolicies.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const linkPolicyToInbox = async policy => {
|
||||
isLinkingPolicy.value = true;
|
||||
try {
|
||||
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
|
||||
assignmentPolicy.value = policy;
|
||||
showPolicyDropdown.value = false;
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
|
||||
} finally {
|
||||
isLinkingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToAssignmentPolicies = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_index',
|
||||
params: { accountId },
|
||||
});
|
||||
};
|
||||
|
||||
const policyMenuItems = computed(() => {
|
||||
const items = availablePolicies.value.map(policy => ({
|
||||
action: 'select_policy',
|
||||
value: policy.id,
|
||||
label: policy.name,
|
||||
icon: 'i-lucide-zap',
|
||||
policy,
|
||||
}));
|
||||
|
||||
items.push({
|
||||
action: 'view_all',
|
||||
value: 'view_all',
|
||||
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
|
||||
icon: 'i-lucide-arrow-right',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handlePolicyMenuAction = ({ action, policy }) => {
|
||||
if (action === 'select_policy' && policy) {
|
||||
linkPolicyToInbox(policy);
|
||||
} else if (action === 'view_all') {
|
||||
navigateToAssignmentPolicies();
|
||||
}
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const togglePolicyDropdown = () => {
|
||||
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
|
||||
fetchAvailablePolicies();
|
||||
}
|
||||
showPolicyDropdown.value = !showPolicyDropdown.value;
|
||||
};
|
||||
|
||||
const closePolicyDropdown = () => {
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const handleToggleAutoAssignment = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
return { v$: useVuelidate(), isEnterprise };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
isAgentListUpdating: false,
|
||||
enableAutoAssignment: false,
|
||||
maxAssignmentLimit: null,
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateAgents = async () => {
|
||||
const agentListIds = selectedAgents.value.map(el => el.id);
|
||||
isAgentListUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxMembers/create', {
|
||||
inboxId: props.inbox.id,
|
||||
agentList: agentListIds,
|
||||
});
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
isAgentListUpdating.value = false;
|
||||
};
|
||||
|
||||
const updateInbox = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
auto_assignment_config: {
|
||||
max_assignment_limit: maxAssignmentLimit.value,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
maxAssignmentLimitErrors() {
|
||||
if (this.v$.maxAssignmentLimit.$error) {
|
||||
return this.$t(
|
||||
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
|
||||
);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
|
||||
this.maxAssignmentLimit =
|
||||
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
|
||||
this.fetchAttachedAgents();
|
||||
},
|
||||
async fetchAttachedAgents() {
|
||||
try {
|
||||
const response = await this.$store.dispatch('inboxMembers/get', {
|
||||
inboxId: this.inbox.id,
|
||||
});
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
this.selectedAgents = inboxMembers;
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
handleEnableAutoAssignment() {
|
||||
this.updateInbox();
|
||||
},
|
||||
async updateAgents() {
|
||||
const agentList = this.selectedAgents.map(el => el.id);
|
||||
this.isAgentListUpdating = true;
|
||||
try {
|
||||
await this.$store.dispatch('inboxMembers/create', {
|
||||
inboxId: this.inbox.id,
|
||||
agentList,
|
||||
});
|
||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
this.isAgentListUpdating = false;
|
||||
},
|
||||
async updateInbox() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: this.enableAutoAssignment,
|
||||
auto_assignment_config: {
|
||||
max_assignment_limit: this.maxAssignmentLimit,
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
isEmpty() {
|
||||
return !!this.selectedAgents.length;
|
||||
},
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
maxAssignmentLimit: {
|
||||
minValue: minValue(1),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const navigateToCreatePolicy = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_create',
|
||||
params: { accountId },
|
||||
query: { inboxId: props.inbox.id },
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToAssignmentPolicyEdit = () => {
|
||||
if (!assignmentPolicy.value?.id) return;
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'agent_assignment_policy_edit',
|
||||
params: { accountId, id: assignmentPolicy.value.id },
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToBilling = () => {
|
||||
const accountId = route.params.accountId;
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId },
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeletePolicy = () => {
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const cancelDeletePolicy = () => {
|
||||
showDeleteConfirmModal.value = false;
|
||||
};
|
||||
|
||||
const deleteAssignmentPolicy = async () => {
|
||||
if (isDeletingPolicy.value) return;
|
||||
isDeletingPolicy.value = true;
|
||||
try {
|
||||
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
|
||||
assignmentPolicy.value = null;
|
||||
showDeleteConfirmModal.value = false;
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
|
||||
} finally {
|
||||
isDeletingPolicy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaults = () => {
|
||||
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
|
||||
maxAssignmentLimit.value =
|
||||
props.inbox.auto_assignment_config?.max_assignment_limit || null;
|
||||
fetchAttachedAgents();
|
||||
if (showAdvancedAssignmentUI.value) {
|
||||
fetchAssignmentPolicy();
|
||||
fetchAvailablePolicies();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch only inbox.id to avoid unnecessary refetches when other properties change
|
||||
watch(() => props.inbox.id, setDefaults);
|
||||
|
||||
onMounted(() => {
|
||||
setDefaults();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -337,6 +138,7 @@ onMounted(() => {
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
@select="v$.selectedAgents.$touch"
|
||||
/>
|
||||
|
||||
<NextButton
|
||||
@@ -350,325 +152,44 @@ onMounted(() => {
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
|
||||
>
|
||||
<!-- New UI for assignment_v2 -->
|
||||
<template v-if="hasAssignmentV2">
|
||||
<div class="flex items-start gap-3">
|
||||
<Switch
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enableAutoAssignment"
|
||||
v-model="enableAutoAssignment"
|
||||
class="flex-shrink-0 mt-0.5"
|
||||
@change="handleToggleAutoAssignment"
|
||||
type="checkbox"
|
||||
@change="handleEnableAutoAssignment"
|
||||
/>
|
||||
<div class="flex-grow">
|
||||
<label class="text-sm text-n-slate-12 font-medium mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<label for="enableAutoAssignment">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="enableAutoAssignment" class="mt-6">
|
||||
<!-- Policy Card - When policy is attached -->
|
||||
<div
|
||||
v-if="showAdvancedAssignmentUI && assignmentPolicy"
|
||||
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
|
||||
>
|
||||
<span class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-base font-medium text-n-slate-12 mb-1">
|
||||
{{ assignmentPolicy.name }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
icon="i-lucide-trash-2"
|
||||
ghost
|
||||
ruby
|
||||
sm
|
||||
@click="confirmDeletePolicy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NextButton
|
||||
color="slate"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
|
||||
@click="cancelDeletePolicy"
|
||||
/>
|
||||
<NextButton
|
||||
color="ruby"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
|
||||
:is-loading="isDeletingPolicy"
|
||||
@click="deleteAssignmentPolicy"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</woot-modal>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,7 +96,7 @@ const actions = {
|
||||
data: payload,
|
||||
});
|
||||
if (!payload.length) {
|
||||
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
|
||||
commit(types.SET_ALL_MESSAGES_LOADED);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
@@ -191,7 +191,7 @@ const actions = {
|
||||
|
||||
async setActiveChat({ commit, dispatch }, { data, after }) {
|
||||
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) {
|
||||
try {
|
||||
await dispatch('fetchPreviousMessages', {
|
||||
@@ -199,7 +199,7 @@ const actions = {
|
||||
before: data.messages[0].id,
|
||||
conversationId: data.id,
|
||||
});
|
||||
commit(types.SET_CHAT_DATA_FETCHED, data.id);
|
||||
data.dataFetched = true;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
@@ -212,17 +212,14 @@ const actions = {
|
||||
conversationId,
|
||||
agentId,
|
||||
});
|
||||
dispatch('setCurrentChatAssignee', {
|
||||
conversationId,
|
||||
assignee: response.data,
|
||||
});
|
||||
dispatch('setCurrentChatAssignee', response.data);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
|
||||
commit(types.ASSIGN_AGENT, { conversationId, assignee });
|
||||
setCurrentChatAssignee({ commit }, assignee) {
|
||||
commit(types.ASSIGN_AGENT, assignee);
|
||||
},
|
||||
|
||||
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
|
||||
|
||||
@@ -63,18 +63,14 @@ export const mutations = {
|
||||
_state.allConversations = [];
|
||||
_state.selectedChatId = null;
|
||||
},
|
||||
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.allMessagesLoaded = true;
|
||||
}
|
||||
[types.SET_ALL_MESSAGES_LOADED](_state) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.allMessagesLoaded = true;
|
||||
},
|
||||
|
||||
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.allMessagesLoaded = false;
|
||||
}
|
||||
[types.CLEAR_ALL_MESSAGES_LOADED](_state) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.allMessagesLoaded = false;
|
||||
},
|
||||
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
|
||||
_state.selectedChatId = null;
|
||||
@@ -95,24 +91,15 @@ export const mutations = {
|
||||
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) {
|
||||
if (activeChat) {
|
||||
_state.selectedChatId = activeChat.id;
|
||||
}
|
||||
},
|
||||
|
||||
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
}
|
||||
[types.ASSIGN_AGENT](_state, assignee) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.meta.assignee = assignee;
|
||||
},
|
||||
|
||||
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
|
||||
@@ -287,10 +274,8 @@ export const mutations = {
|
||||
|
||||
// Update assignee on action cable message
|
||||
[types.UPDATE_ASSIGNEE](_state, payload) {
|
||||
const chat = getConversationById(_state)(payload.id);
|
||||
if (chat) {
|
||||
chat.meta.assignee = payload.assignee;
|
||||
}
|
||||
const [chat] = _state.allConversations.filter(c => c.id === payload.id);
|
||||
chat.meta.assignee = payload.assignee;
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
|
||||
|
||||
@@ -355,26 +355,22 @@ describe('#actions', () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: { id: 1, name: 'User' },
|
||||
});
|
||||
await actions.assignAgent(
|
||||
{ dispatch },
|
||||
{ conversationId: 1, agentId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
});
|
||||
await actions.assignAgent({ commit }, { conversationId: 1, agentId: 1 });
|
||||
expect(commit).toHaveBeenCalledTimes(0);
|
||||
expect(commit.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setCurrentChatAssignee', () => {
|
||||
it('sends correct mutations if assignment is successful', async () => {
|
||||
const payload = {
|
||||
conversationId: 1,
|
||||
assignee: { id: 1, name: 'User' },
|
||||
};
|
||||
await actions.setCurrentChatAssignee({ commit }, payload);
|
||||
axios.post.mockResolvedValue({
|
||||
data: { id: 1, name: 'User' },
|
||||
});
|
||||
await actions.setCurrentChatAssignee({ commit }, { id: 1, name: 'User' });
|
||||
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', () => {
|
||||
it('fetches inbox assistant by id', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
|
||||
@@ -570,84 +570,25 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CHAT_DATA_FETCHED', () => {
|
||||
it('should set dataFetched to true on the conversation by ID', () => {
|
||||
describe('#SET_ALL_MESSAGES_LOADED', () => {
|
||||
it('should set allMessagesLoaded to true on selected chat', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1 }, { id: 2 }],
|
||||
};
|
||||
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' }] }],
|
||||
allConversations: [{ id: 1, allMessagesLoaded: false }],
|
||||
selectedChatId: 1,
|
||||
};
|
||||
const originalRef = state.allConversations[0];
|
||||
|
||||
// 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);
|
||||
mutations[types.SET_ALL_MESSAGES_LOADED](state);
|
||||
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', () => {
|
||||
it('should set allMessagesLoaded to false on the conversation by ID', () => {
|
||||
it('should set allMessagesLoaded to false on selected chat', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, allMessagesLoaded: true },
|
||||
{ id: 2, allMessagesLoaded: true },
|
||||
],
|
||||
allConversations: [{ id: 1, allMessagesLoaded: true }],
|
||||
selectedChatId: 1,
|
||||
};
|
||||
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[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', () => {
|
||||
it('should assign agent to the correct conversation by ID', () => {
|
||||
it('should assign agent to selected conversation', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, meta: {} },
|
||||
{ id: 2, meta: {} },
|
||||
],
|
||||
selectedChatId: 2,
|
||||
allConversations: [{ id: 1, meta: {} }],
|
||||
selectedChatId: 1,
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
});
|
||||
mutations[types.ASSIGN_AGENT](state, 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);
|
||||
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', () => {
|
||||
|
||||
@@ -64,7 +64,6 @@ export default {
|
||||
|
||||
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',
|
||||
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
|
||||
|
||||
|
||||
@@ -27,9 +27,7 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
|
||||
rescue Down::NotFound
|
||||
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||
rescue Down::Error => e
|
||||
rescue Down::NotFound, Down::Error => e
|
||||
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
||||
ensure
|
||||
update_avatar_sync_attributes(avatarable, avatar_url)
|
||||
|
||||
@@ -2,6 +2,19 @@ class Internal::SeedAccountJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account)
|
||||
# Reload account to ensure we have fresh data
|
||||
account.reload
|
||||
|
||||
# Perform seeding
|
||||
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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
class ReplyMailbox < ApplicationMailbox
|
||||
include IncomingEmailValidityHelper
|
||||
|
||||
attr_accessor :conversation, :processed_mail, :account
|
||||
attr_accessor :conversation, :processed_mail
|
||||
|
||||
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 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
|
||||
# This prevents orphan conversations if message/attachment creation fails
|
||||
# and ensures idempotency on job retry (conversation won't be duplicated)
|
||||
ActiveRecord::Base.transaction do
|
||||
persist_conversation_if_needed
|
||||
decorate_mail
|
||||
create_message
|
||||
add_attachments_to_message
|
||||
end
|
||||
@@ -29,7 +22,6 @@ class ReplyMailbox < ApplicationMailbox
|
||||
|
||||
def find_conversation
|
||||
@conversation = Mailbox::ConversationFinder.new(mail).find
|
||||
@account = @conversation&.account
|
||||
# Log when email is rejected
|
||||
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
|
||||
end
|
||||
@@ -44,6 +36,6 @@ class ReplyMailbox < ApplicationMailbox
|
||||
end
|
||||
|
||||
def decorate_mail
|
||||
@processed_mail = MailPresenter.new(mail, @account)
|
||||
@processed_mail = MailPresenter.new(mail, @conversation.account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,7 +40,6 @@ class Account < ApplicationRecord
|
||||
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -89,7 +88,6 @@ class Account < ApplicationRecord
|
||||
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
|
||||
has_many :account_users, 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
|
||||
end
|
||||
|
||||
def email_transcript_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def within_email_rate_limit?
|
||||
return true if emails_sent_today < email_rate_limit
|
||||
|
||||
|
||||
@@ -130,15 +130,11 @@ class MailPresenter < SimpleDelegator
|
||||
end
|
||||
|
||||
def sender_name
|
||||
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name
|
||||
Mail::Address.new((@mail[:reply_to] || @mail[:from]).value).name
|
||||
end
|
||||
|
||||
def original_sender
|
||||
[
|
||||
@mail[:reply_to]&.value,
|
||||
@mail['X-Original-Sender']&.value,
|
||||
@mail[:from]&.value
|
||||
].filter_map { |email| parse_mail_address(email)&.address }.first
|
||||
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
|
||||
end
|
||||
|
||||
def headers_data
|
||||
@@ -151,6 +147,10 @@ class MailPresenter < SimpleDelegator
|
||||
headers.presence
|
||||
end
|
||||
|
||||
def from_email_address(email)
|
||||
Mail::Address.new(email).address
|
||||
end
|
||||
|
||||
def email_forwarded_for
|
||||
@mail['X-Forwarded-For'].try(:value)
|
||||
end
|
||||
@@ -174,39 +174,12 @@ class MailPresenter < SimpleDelegator
|
||||
end
|
||||
|
||||
def notification_email_from_chatwoot?
|
||||
sender_address = original_sender.to_s.downcase
|
||||
return false if sender_address.blank?
|
||||
|
||||
# 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)
|
||||
# notification emails are send via mailer sender email address. so it should match
|
||||
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||
end
|
||||
|
||||
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?
|
||||
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
|
||||
end
|
||||
|
||||
@@ -75,8 +75,6 @@ class ActionService
|
||||
end
|
||||
|
||||
def send_email_transcript(emails)
|
||||
return unless @account.email_transcript_enabled?
|
||||
|
||||
emails = emails[0].gsub(/\s+/, '').split(',')
|
||||
|
||||
emails.each do |email|
|
||||
|
||||
@@ -3,7 +3,6 @@ class AutoAssignment::AssignmentService
|
||||
|
||||
def perform_bulk_assignment(limit: 100)
|
||||
return 0 unless inbox.auto_assignment_v2_enabled?
|
||||
return 0 unless inbox.enable_auto_assignment?
|
||||
|
||||
assigned_count = 0
|
||||
|
||||
@@ -33,9 +32,7 @@ class AutoAssignment::AssignmentService
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# Apply conversation priority using assignment policy if available
|
||||
policy = inbox.assignment_policy
|
||||
scope = if policy&.longest_waiting?
|
||||
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
|
||||
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.reorder(created_at: :asc)
|
||||
@@ -84,6 +81,10 @@ class AutoAssignment::AssignmentService
|
||||
def round_robin_selector
|
||||
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
|
||||
def assignment_config
|
||||
@assignment_config ||= inbox.auto_assignment_config || {}
|
||||
end
|
||||
end
|
||||
|
||||
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
|
||||
|
||||
@@ -8,6 +8,8 @@ class AutoAssignment::RateLimiter
|
||||
end
|
||||
|
||||
def track_assignment(conversation)
|
||||
return unless enabled?
|
||||
|
||||
assignment_key = build_assignment_key(conversation.id)
|
||||
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
|
||||
end
|
||||
@@ -22,11 +24,11 @@ class AutoAssignment::RateLimiter
|
||||
private
|
||||
|
||||
def enabled?
|
||||
config.present? && limit.positive?
|
||||
limit.present? && limit.positive?
|
||||
end
|
||||
|
||||
def limit
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
|
||||
config&.fair_distribution_limit&.to_i || Math
|
||||
end
|
||||
|
||||
def window
|
||||
|
||||
@@ -2,6 +2,7 @@ class MessageTemplates::HookExecutionService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return if conversation.campaign.present?
|
||||
return if conversation.last_incoming_message.blank?
|
||||
return if message.auto_reply_email?
|
||||
|
||||
@@ -20,7 +21,6 @@ class MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# should not send for outbound messages
|
||||
@@ -37,7 +37,6 @@ class MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
def should_send_greeting?
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
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
|
||||
def should_send_email_collect?
|
||||
return false if conversation.campaign.present?
|
||||
|
||||
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
|
||||
end
|
||||
|
||||
|
||||
@@ -20,17 +20,12 @@ class Messages::SendEmailNotificationService
|
||||
|
||||
def should_send_email_notification?
|
||||
return false unless message.email_notifiable_message?
|
||||
return false if bot_sender_message?
|
||||
return false if message.conversation.contact.email.blank?
|
||||
return false unless message.account.within_email_rate_limit?
|
||||
|
||||
email_reply_enabled?
|
||||
end
|
||||
|
||||
def bot_sender_message?
|
||||
message.sender_type.in?(%w[AgentBot Captain::Assistant])
|
||||
end
|
||||
|
||||
def email_reply_enabled?
|
||||
inbox = message.inbox
|
||||
case inbox.channel.class.to_s
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
require 'faraday/multipart'
|
||||
|
||||
# Telegram Attachment APIs: ref: https://core.telegram.org/bots/api#inputfile
|
||||
|
||||
# Media attachments like photos, videos can be clubbed together and sent as a media group
|
||||
@@ -113,33 +111,17 @@ class Telegram::SendAttachmentsService
|
||||
|
||||
def send_file(chat_id, file_path, reply_to_message_id)
|
||||
File.open(file_path, 'rb') do |file|
|
||||
file_name = File.basename(file_path)
|
||||
mime_type = Marcel::MimeType.for(name: file_name) || 'application/octet-stream'
|
||||
|
||||
payload = { chat_id: chat_id, document: Faraday::Multipart::FilePart.new(file, mime_type, file_name) }
|
||||
payload[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
||||
payload.merge!(business_connection_body)
|
||||
|
||||
response = multipart_post_connection.post("#{channel.telegram_api_url}/sendDocument", payload)
|
||||
parse_faraday_response(response)
|
||||
HTTParty.post("#{channel.telegram_api_url}/sendDocument",
|
||||
body: {
|
||||
chat_id: chat_id,
|
||||
**business_connection_body,
|
||||
document: file,
|
||||
reply_to_message_id: reply_to_message_id
|
||||
},
|
||||
multipart: true)
|
||||
end
|
||||
end
|
||||
|
||||
def multipart_post_connection
|
||||
@multipart_post_connection ||= Faraday.new do |f|
|
||||
f.request :multipart
|
||||
f.options.timeout = 300
|
||||
f.options.open_timeout = 60
|
||||
end
|
||||
end
|
||||
|
||||
def parse_faraday_response(response)
|
||||
parsed = JSON.parse(response.body)
|
||||
OpenStruct.new(success?: response.success?, parsed_response: parsed)
|
||||
rescue JSON::ParserError
|
||||
OpenStruct.new(success?: false, parsed_response: { 'ok' => false, 'error_code' => response.status, 'description' => response.reason_phrase })
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
return true if response.success?
|
||||
|
||||
|
||||
@@ -241,7 +241,3 @@
|
||||
display_name: Required Conversation Attributes
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: advanced_assignment
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
|
||||
+698
-45
@@ -18,80 +18,733 @@ unless Rails.env.production?
|
||||
GlobalConfig.clear_cache
|
||||
|
||||
account = Account.create!(
|
||||
name: 'Acme Inc'
|
||||
name: 'Paperlayer'
|
||||
)
|
||||
|
||||
secondary_account = Account.create!(
|
||||
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.save!
|
||||
|
||||
AccountUser.create!(
|
||||
account_id: account.id,
|
||||
user_id: user.id,
|
||||
role: :administrator
|
||||
# Create Sales Team
|
||||
sales_lead = User.new(name: 'Marcus Chen', email: 'marcus@paperlayer.com', password: 'Password1!')
|
||||
sales_lead.skip_confirmation!
|
||||
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!(
|
||||
account_id: secondary_account.id,
|
||||
user_id: user.id,
|
||||
role: :administrator
|
||||
support_team = Team.create!(
|
||||
account: account,
|
||||
name: 'Customer Support',
|
||||
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: 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(
|
||||
source_id: user.id,
|
||||
sales_inbox = Inbox.create!(channel: Channel::WebWidget.create!(account: account, website_url: 'https://paperlayer.com/sales'), account: account,
|
||||
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,
|
||||
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
|
||||
|
||||
conversation = Conversation.create!(
|
||||
demo_conversation = Conversation.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
status: :open,
|
||||
assignee: user,
|
||||
contact: contact_inbox.contact,
|
||||
contact_inbox: contact_inbox,
|
||||
assignee: sales_lead,
|
||||
contact: special_contact.contact,
|
||||
contact_inbox: special_contact,
|
||||
additional_attributes: {}
|
||||
)
|
||||
demo_conversation.label_list.add(bulk_order_label.title)
|
||||
demo_conversation.save!
|
||||
|
||||
# sample email collect
|
||||
Seeders::MessageSeeder.create_sample_email_collect_message conversation
|
||||
Message.create!(content: 'Hi! I\'m interested in learning more about your products.', account: account, inbox: inbox,
|
||||
conversation: demo_conversation, sender: special_contact.contact, message_type: :incoming)
|
||||
|
||||
Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, sender: contact_inbox.contact,
|
||||
message_type: :incoming)
|
||||
# Add interactive message samples
|
||||
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
|
||||
#
|
||||
location_message = Message.new(content: 'location', account: account, inbox: inbox, sender: contact_inbox.contact, conversation: conversation,
|
||||
message_type: :incoming)
|
||||
location_message.attachments.new(
|
||||
account_id: account.id,
|
||||
file_type: 'location',
|
||||
coordinates_lat: 37.7893768,
|
||||
coordinates_long: -122.3895553,
|
||||
fallback_title: 'Bay Bridge, San Francisco, CA, USA'
|
||||
puts ' ✓ Created 50+ conversations with diverse scenarios'
|
||||
|
||||
# ===============================================
|
||||
# Help Center Portal Setup
|
||||
# ===============================================
|
||||
portal = Portal.create!(
|
||||
account: account,
|
||||
name: 'Paperlayer Help Center',
|
||||
slug: 'dunder-mifflin-help',
|
||||
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
|
||||
Seeders::MessageSeeder.create_sample_cards_message conversation
|
||||
# input select
|
||||
Seeders::MessageSeeder.create_sample_input_select_message conversation
|
||||
# form
|
||||
Seeders::MessageSeeder.create_sample_form_message conversation
|
||||
# articles
|
||||
Seeders::MessageSeeder.create_sample_articles_message conversation
|
||||
# csat
|
||||
Seeders::MessageSeeder.create_sample_csat_collect_message conversation
|
||||
# Category: Getting Started
|
||||
getting_started_category = Category.create!(
|
||||
account: account,
|
||||
portal: portal,
|
||||
name: 'Getting Started',
|
||||
slug: 'getting-started',
|
||||
description: 'Learn the basics of ordering from Paperlayer',
|
||||
locale: 'en',
|
||||
position: 1,
|
||||
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
|
||||
|
||||
@@ -93,10 +93,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,12 +3,6 @@ module Enterprise::Account
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
|
||||
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||
def selected_feature_flags=(features)
|
||||
super
|
||||
sync_assignment_features
|
||||
end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
@@ -37,21 +31,4 @@ module Enterprise::Account
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_assignment_features
|
||||
if feature_enabled?('assignment_v2')
|
||||
# Enable advanced_assignment for Business/Enterprise plans
|
||||
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
|
||||
else
|
||||
# Disable advanced_assignment when assignment_v2 is disabled
|
||||
send('feature_advanced_assignment=', false)
|
||||
end
|
||||
end
|
||||
|
||||
def business_or_enterprise_plan?
|
||||
plan_name = custom_attributes['plan_name']
|
||||
%w[Business Enterprise].include?(plan_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,13 +32,6 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
|
||||
save
|
||||
end
|
||||
|
||||
def email_transcript_enabled?
|
||||
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
|
||||
return true if default_plan.blank?
|
||||
|
||||
plan_name.present? && plan_name != default_plan['name']
|
||||
end
|
||||
|
||||
def email_rate_limit
|
||||
account_limit || plan_email_limit || global_limit || default_limit
|
||||
end
|
||||
|
||||
@@ -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:)
|
||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||
|
||||
translated_query = Captain::Llm::TranslateQueryService
|
||||
.new(account: assistant.account)
|
||||
.translate(query, target_language: assistant.account.locale_english_name)
|
||||
|
||||
responses = assistant.responses.approved.search(translated_query)
|
||||
responses = assistant.responses.approved.search(query)
|
||||
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
|
||||
|
||||
@@ -18,11 +18,7 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
def execute(query:)
|
||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||
|
||||
translated_query = Captain::Llm::TranslateQueryService
|
||||
.new(account: @account)
|
||||
.translate(query, target_language: @account.locale_english_name)
|
||||
|
||||
responses = search_responses(translated_query)
|
||||
responses = search_responses(query)
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
|
||||
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?
|
||||
return nil if agents.empty?
|
||||
|
||||
# Use balanced selector only if advanced_assignment feature is enabled
|
||||
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
|
||||
selector = policy&.balanced? ? balanced_selector : round_robin_selector
|
||||
selector.select_agent(agents)
|
||||
end
|
||||
|
||||
@@ -32,7 +31,7 @@ module Enterprise::AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('advanced_assignment') &&
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes 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
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
|
||||
@@ -68,10 +68,6 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module Captain::ToolInstrumentation
|
||||
extend ActiveSupport::Concern
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
private
|
||||
|
||||
@@ -11,10 +10,15 @@ module Captain::ToolInstrumentation
|
||||
response = nil
|
||||
executed = false
|
||||
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
|
||||
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
|
||||
response
|
||||
rescue StandardError => e
|
||||
@@ -22,24 +26,17 @@ module Captain::ToolInstrumentation
|
||||
executed ? response : yield
|
||||
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)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||
|
||||
tracer.in_span("llm.#{event_name}.generation") do |span|
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
|
||||
span.set_attribute(ATTR_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(ATTR_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('gen_ai.system', 'openai')
|
||||
span.set_attribute('gen_ai.request.model', model)
|
||||
span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens)
|
||||
span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens)
|
||||
span.set_attribute('langfuse.observation.input', format_chat_messages(chat))
|
||||
span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to record generation: #{e.message}"
|
||||
|
||||
@@ -33,7 +33,7 @@ General guidelines:
|
||||
- Reply in the customer's language
|
||||
{% 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.
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -102,8 +102,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
def send_message
|
||||
post_message if message_content.present?
|
||||
upload_files if message.attachments.any?
|
||||
rescue Slack::Web::Api::Errors::IsArchived, Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope,
|
||||
Slack::Web::Api::Errors::InvalidAuth,
|
||||
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
|
||||
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
|
||||
Rails.logger.error e
|
||||
hook.prompt_reauthorization!
|
||||
|
||||
@@ -2,10 +2,13 @@ module Linear::Mutations
|
||||
def self.graphql_value(value)
|
||||
case value
|
||||
when String
|
||||
value.to_json
|
||||
# Strings must be enclosed in double quotes
|
||||
"\"#{value.gsub("\n", '\\n')}\""
|
||||
when Array
|
||||
# Arrays need to be recursively converted
|
||||
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
|
||||
else
|
||||
# Other types (numbers, booleans) can be directly converted to strings
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
@@ -44,7 +47,7 @@ module Linear::Mutations
|
||||
|
||||
<<~GRAPHQL
|
||||
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
|
||||
attachment {
|
||||
id
|
||||
|
||||
@@ -48,7 +48,7 @@ module Linear::Queries
|
||||
def self.search_issue(term)
|
||||
<<~GRAPHQL
|
||||
query {
|
||||
searchIssues(term: #{Linear::Mutations.graphql_value(term)}) {
|
||||
searchIssues(term: "#{term}") {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LlmConstants
|
||||
DEFAULT_MODEL = 'gpt-4.1'
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||
|
||||
|
||||
@@ -23,22 +23,45 @@ class Seeders::AccountSeeder
|
||||
set_up_users
|
||||
seed_labels
|
||||
seed_canned_responses
|
||||
seed_macros
|
||||
seed_inboxes
|
||||
seed_contacts
|
||||
seed_portals
|
||||
seed_captain_assistants
|
||||
finalize_seeding
|
||||
end
|
||||
|
||||
def set_up_account
|
||||
# Delete all existing data before seeding
|
||||
@account.macros.destroy_all
|
||||
@account.canned_responses.destroy_all
|
||||
@account.teams.destroy_all
|
||||
@account.conversations.destroy_all
|
||||
@account.labels.destroy_all
|
||||
@account.portals.destroy_all
|
||||
@account.inboxes.destroy_all
|
||||
@account.contacts.destroy_all
|
||||
@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
|
||||
|
||||
def seed_teams
|
||||
@account_data['teams'].each do |team_name|
|
||||
@account.teams.create!(name: team_name)
|
||||
@account_data['teams'].each do |team_data|
|
||||
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
|
||||
|
||||
@@ -103,9 +126,68 @@ class Seeders::AccountSeeder
|
||||
@account.custom_roles.find_by(name: role_name)
|
||||
end
|
||||
|
||||
def seed_canned_responses(count: 50)
|
||||
count.times do
|
||||
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
|
||||
def seed_canned_responses
|
||||
return unless @account_data['canned_responses'].present?
|
||||
|
||||
@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
|
||||
|
||||
@@ -155,4 +237,106 @@ class Seeders::AccountSeeder
|
||||
def seed_inboxes
|
||||
Seeders::InboxSeeder.new(account: @account, company_data: @account_data[:company]).perform!
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ module Seeders::MessageSeeder
|
||||
conversation: conversation,
|
||||
message_type: :template,
|
||||
content_type: :input_email,
|
||||
content: 'Get notified by email'
|
||||
content: 'Want us to email you a copy of your quote?'
|
||||
)
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ module Seeders::MessageSeeder
|
||||
conversation: conversation,
|
||||
message_type: :template,
|
||||
content_type: :input_csat,
|
||||
content: 'Please rate the support'
|
||||
content: 'How would you rate your experience with Paperlayer today?'
|
||||
)
|
||||
end
|
||||
|
||||
@@ -41,18 +41,18 @@ module Seeders::MessageSeeder
|
||||
def self.sample_card_item
|
||||
{
|
||||
media_url: 'https://i.imgur.com/d8Djr4k.jpg',
|
||||
title: 'Acme Shoes 2.0',
|
||||
description: 'Move with Acme Shoe 2.0',
|
||||
title: 'Premium Copy Paper - 20lb',
|
||||
description: 'Bright white, 500 sheets per ream. Perfect for everyday printing.',
|
||||
actions: [
|
||||
{
|
||||
type: 'link',
|
||||
text: 'View More',
|
||||
uri: 'http://acme-shoes.inc'
|
||||
text: 'View Details',
|
||||
uri: 'http://paperlayer.com/products/copy-paper'
|
||||
},
|
||||
{
|
||||
type: 'postback',
|
||||
text: 'Add to cart',
|
||||
payload: 'ITEM_SELECTED'
|
||||
text: 'Request Quote',
|
||||
payload: 'QUOTE_REQUESTED'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -64,14 +64,14 @@ module Seeders::MessageSeeder
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
message_type: :template,
|
||||
content: 'Your favorite food',
|
||||
content: 'What type of paper are you interested in?',
|
||||
content_type: 'input_select',
|
||||
content_attributes: {
|
||||
items: [
|
||||
{ title: '🌯 Burito', value: 'Burito' },
|
||||
{ title: '🍝 Pasta', value: 'Pasta' },
|
||||
{ title: ' 🍱 Sushi', value: 'Sushi' },
|
||||
{ title: ' 🥗 Salad', value: 'Salad' }
|
||||
{ title: '📄 Copy Paper (20lb)', value: 'copy-paper-20' },
|
||||
{ title: '📋 Cardstock (110lb)', value: 'cardstock-110' },
|
||||
{ title: '🎨 Colored Paper', value: 'colored-paper' },
|
||||
{ title: '✨ Premium (32lb)', value: 'premium-32' }
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -92,14 +92,20 @@ module Seeders::MessageSeeder
|
||||
def self.sample_form
|
||||
{
|
||||
items: [
|
||||
{ name: 'email', placeholder: 'Please enter your email', type: 'email', label: 'Email', required: 'required',
|
||||
pattern_error: 'Please fill this field', pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$' },
|
||||
{ name: 'text_area', placeholder: 'Please enter text', type: 'text_area', label: 'Large Text', required: 'required',
|
||||
pattern_error: 'Please fill this field' },
|
||||
{ name: 'text', placeholder: 'Please enter text', type: 'text', label: 'text', default: 'defaut value', required: 'required',
|
||||
pattern: '^[a-zA-Z ]*$', pattern_error: 'Only alphabets are allowed' },
|
||||
{ name: 'select', label: 'Select Option', type: 'select', options: [{ label: '🌯 Burito', value: 'Burito' },
|
||||
{ label: '🍝 Pasta', value: 'Pasta' }] }
|
||||
{ name: 'company', placeholder: 'Your company name', type: 'text', label: 'Company Name', required: 'required',
|
||||
pattern_error: 'Please enter your company name' },
|
||||
{ name: 'email', placeholder: 'your.email@company.com', type: 'email', label: 'Email Address', required: 'required',
|
||||
pattern_error: 'Please enter a valid email', pattern: '^[^\s@]+@[^\s@]+\.[^\s@]+$' },
|
||||
{ name: 'quantity', placeholder: 'e.g., 50 cases', type: 'text', label: 'Quantity Needed', required: 'required',
|
||||
pattern_error: 'Please specify quantity' },
|
||||
{ name: 'paper_type', label: 'Paper Type', type: 'select', options: [
|
||||
{ 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
|
||||
@@ -110,12 +116,14 @@ module Seeders::MessageSeeder
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
message_type: :template,
|
||||
content: 'Tech Companies',
|
||||
content: 'Helpful Resources',
|
||||
content_type: 'article',
|
||||
content_attributes: {
|
||||
items: [
|
||||
{ title: 'Acme Hardware', description: 'Hardware reimagined', link: 'http://acme-hardware.inc' },
|
||||
{ title: 'Acme Search', description: 'The best Search Engine', link: 'http://acme-search.inc' }
|
||||
{ title: 'Paper Weight Guide', description: 'Understanding paper weights and their best uses',
|
||||
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
File diff suppressed because it is too large
Load Diff
+5
-10
@@ -36,21 +36,16 @@ class Webhooks::Trigger
|
||||
|
||||
case @webhook_type
|
||||
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
|
||||
update_message_status(error)
|
||||
end
|
||||
end
|
||||
|
||||
def update_conversation_status(message)
|
||||
conversation = message.conversation
|
||||
return unless conversation&.pending?
|
||||
return if conversation&.account&.keep_pending_on_bot_failure
|
||||
|
||||
conversation.open!
|
||||
create_agent_bot_error_activity(conversation)
|
||||
end
|
||||
|
||||
def create_agent_bot_error_activity(conversation)
|
||||
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
|
||||
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
|
||||
|
||||
+57
-98
@@ -1,108 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<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">
|
||||
<title>Page not found</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
|
||||
background: rgba(39, 129, 246, 0.05);
|
||||
color: rgb(28, 32, 36);
|
||||
padding: 2rem 1.5rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
.error-number {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(180deg, rgb(39, 129, 246) 0%, rgb(155, 195, 252) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.description {
|
||||
font-size: 0.9375rem;
|
||||
color: rgb(96, 100, 108);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgb(39, 129, 246);
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.625rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.btn:hover { background: rgb(16, 115, 233); }
|
||||
.btn svg { width: 1.125rem; height: 1.125rem; }
|
||||
.divider {
|
||||
width: 3rem;
|
||||
height: 2px;
|
||||
background: rgb(224, 225, 230);
|
||||
border-radius: 1px;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.help {
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(139, 141, 152);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
|
||||
.error-number { background: linear-gradient(180deg, rgb(126, 182, 255) 0%, rgb(40, 89, 156) 100%); -webkit-background-clip: text; background-clip: text; }
|
||||
.description { color: rgb(176, 180, 186); }
|
||||
.divider { background: rgb(46, 49, 53); }
|
||||
.help { color: rgb(105, 110, 119); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.error-number { font-size: 5rem; }
|
||||
h1 { font-size: 1.25rem; }
|
||||
}
|
||||
body {
|
||||
background-color: #EFEFEF;
|
||||
color: #2E2F30;
|
||||
text-align: center;
|
||||
font-family: arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div.dialog {
|
||||
width: 95%;
|
||||
max-width: 33em;
|
||||
margin: 4em auto 0;
|
||||
}
|
||||
|
||||
div.dialog > div {
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #BBB;
|
||||
border-top: #B00100 solid 4px;
|
||||
border-top-left-radius: 9px;
|
||||
border-top-right-radius: 9px;
|
||||
background-color: white;
|
||||
padding: 7px 12% 0;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 100%;
|
||||
color: #730E15;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
div.dialog > p {
|
||||
margin: 0 0 1em;
|
||||
padding: 1em;
|
||||
background-color: #F7F7F7;
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #999;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-color: #DADADA;
|
||||
color: #666;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="error-number">404</div>
|
||||
<h1>Page not found</h1>
|
||||
<p class="description">The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="/" class="btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||
Back to home
|
||||
</a>
|
||||
<div class="divider"></div>
|
||||
<p class="help">If you think this is a mistake, please reach out to support.</p>
|
||||
<!-- This file lives in public/404.html -->
|
||||
<div class="dialog">
|
||||
<div>
|
||||
<h1>The page you were looking for doesn't exist.</h1>
|
||||
<p>You may have mistyped the address or the page may have moved.</p>
|
||||
</div>
|
||||
<p>If you are the application owner check the logs for more information.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+57
-103
@@ -1,113 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>The change you wanted was rejected (422)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Request rejected</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
|
||||
background: rgba(39, 129, 246, 0.05);
|
||||
color: rgb(28, 32, 36);
|
||||
padding: 2rem 1.5rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
.error-number {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(180deg, rgb(229, 70, 102) 0%, rgb(248, 191, 200) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.description {
|
||||
font-size: 0.9375rem;
|
||||
color: rgb(96, 100, 108);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgb(39, 129, 246);
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.625rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.btn:hover { background: rgb(16, 115, 233); }
|
||||
.btn svg { width: 1.125rem; height: 1.125rem; }
|
||||
.divider {
|
||||
width: 3rem;
|
||||
height: 2px;
|
||||
background: rgb(224, 225, 230);
|
||||
border-radius: 1px;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.help {
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(139, 141, 152);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.help a {
|
||||
color: rgb(39, 129, 246);
|
||||
text-decoration: none;
|
||||
}
|
||||
.help a:hover { text-decoration: underline; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
|
||||
.error-number { background: linear-gradient(180deg, rgb(255, 148, 157) 0%, rgb(136, 52, 71) 100%); -webkit-background-clip: text; background-clip: text; }
|
||||
.description { color: rgb(176, 180, 186); }
|
||||
.divider { background: rgb(46, 49, 53); }
|
||||
.help { color: rgb(105, 110, 119); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.error-number { font-size: 5rem; }
|
||||
h1 { font-size: 1.25rem; }
|
||||
}
|
||||
body {
|
||||
background-color: #EFEFEF;
|
||||
color: #2E2F30;
|
||||
text-align: center;
|
||||
font-family: arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div.dialog {
|
||||
width: 95%;
|
||||
max-width: 33em;
|
||||
margin: 4em auto 0;
|
||||
}
|
||||
|
||||
div.dialog > div {
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #BBB;
|
||||
border-top: #B00100 solid 4px;
|
||||
border-top-left-radius: 9px;
|
||||
border-top-right-radius: 9px;
|
||||
background-color: white;
|
||||
padding: 7px 12% 0;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 100%;
|
||||
color: #730E15;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
div.dialog > p {
|
||||
margin: 0 0 1em;
|
||||
padding: 1em;
|
||||
background-color: #F7F7F7;
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #999;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-color: #DADADA;
|
||||
color: #666;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="error-number">422</div>
|
||||
<h1>Request rejected</h1>
|
||||
<p class="description">Your request couldn't be processed due to a security verification failure or invalid data.</p>
|
||||
<a href="/" class="btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||
Back to home
|
||||
</a>
|
||||
<div class="divider"></div>
|
||||
<p class="help">If this keeps happening, try clearing your cookies and refreshing.</p>
|
||||
<!-- This file lives in public/422.html -->
|
||||
<div class="dialog">
|
||||
<div>
|
||||
<h1>The change you wanted was rejected.</h1>
|
||||
<p>Maybe you tried to change something you didn't have access to.</p>
|
||||
</div>
|
||||
<p>If you are the application owner check the logs for more information.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+56
-98
@@ -1,108 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<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">
|
||||
<title>Something went wrong</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
|
||||
background: rgba(39, 129, 246, 0.05);
|
||||
color: rgb(28, 32, 36);
|
||||
padding: 2rem 1.5rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
.error-number {
|
||||
font-size: 8rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(180deg, rgb(207, 74, 34) 0%, rgb(247, 164, 120) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.description {
|
||||
font-size: 0.9375rem;
|
||||
color: rgb(96, 100, 108);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: rgb(39, 129, 246);
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.625rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.btn:hover { background: rgb(16, 115, 233); }
|
||||
.btn svg { width: 1.125rem; height: 1.125rem; }
|
||||
.divider {
|
||||
width: 3rem;
|
||||
height: 2px;
|
||||
background: rgb(224, 225, 230);
|
||||
border-radius: 1px;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.help {
|
||||
font-size: 0.8125rem;
|
||||
color: rgb(139, 141, 152);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: rgb(17, 17, 19); color: rgb(237, 238, 240); }
|
||||
.error-number { background: linear-gradient(180deg, rgb(255, 148, 114) 0%, rgb(148, 52, 27) 100%); -webkit-background-clip: text; background-clip: text; }
|
||||
.description { color: rgb(176, 180, 186); }
|
||||
.divider { background: rgb(46, 49, 53); }
|
||||
.help { color: rgb(105, 110, 119); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.error-number { font-size: 5rem; }
|
||||
h1 { font-size: 1.25rem; }
|
||||
}
|
||||
body {
|
||||
background-color: #EFEFEF;
|
||||
color: #2E2F30;
|
||||
text-align: center;
|
||||
font-family: arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div.dialog {
|
||||
width: 95%;
|
||||
max-width: 33em;
|
||||
margin: 4em auto 0;
|
||||
}
|
||||
|
||||
div.dialog > div {
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #BBB;
|
||||
border-top: #B00100 solid 4px;
|
||||
border-top-left-radius: 9px;
|
||||
border-top-right-radius: 9px;
|
||||
background-color: white;
|
||||
padding: 7px 12% 0;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 100%;
|
||||
color: #730E15;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
div.dialog > p {
|
||||
margin: 0 0 1em;
|
||||
padding: 1em;
|
||||
background-color: #F7F7F7;
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #999;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-color: #DADADA;
|
||||
color: #666;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="error-number">500</div>
|
||||
<h1>Something went wrong</h1>
|
||||
<p class="description">An unexpected error occurred. Please refresh the page or try again shortly.</p>
|
||||
<a href="/" class="btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||
Back to home
|
||||
</a>
|
||||
<div class="divider"></div>
|
||||
<p class="help">If the problem persists, please try again in a few minutes.</p>
|
||||
<!-- This file lives in public/500.html -->
|
||||
<div class="dialog">
|
||||
<div>
|
||||
<h1>We're sorry, but something went wrong.</h1>
|
||||
</div>
|
||||
<p>If you are the application owner check the logs for more information.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -144,13 +144,13 @@ RSpec.describe Concerns::Agentable do
|
||||
it 'returns default model when config not found' do
|
||||
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
|
||||
|
||||
it 'returns default model when config value is nil' do
|
||||
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
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@ RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
# Enable assignment_v2 (base) and advanced_assignment (premium) features
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Set agents as online
|
||||
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'
|
||||
})
|
||||
|
||||
# Enable assignment_v2 (base) and advanced_assignment (premium) features
|
||||
account.enable_features('assignment_v2', 'advanced_assignment')
|
||||
account.save!
|
||||
# Enable assignment_v2 feature
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Create existing assignments for agent_at_capacity (at limit)
|
||||
3.times do
|
||||
|
||||
@@ -238,77 +238,6 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
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
|
||||
before do
|
||||
account.update!(
|
||||
|
||||
@@ -14,13 +14,13 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
describe '#perform' do
|
||||
context 'when account has assignment_v2 feature enabled' do
|
||||
before do
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
context 'when inbox has assignment policy or auto assignment enabled' do
|
||||
context 'when inbox has auto_assignment_v2 enabled' do
|
||||
before do
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(account).to receive(:inboxes).and_return(inbox_relation)
|
||||
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
|
||||
@@ -41,8 +41,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
policy2 = create(:assignment_policy, account: account2)
|
||||
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
|
||||
|
||||
account2.enable_features('assignment_v2')
|
||||
account2.save!
|
||||
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation2 = instance_double(ActiveRecord::Relation)
|
||||
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
|
||||
@@ -58,10 +58,9 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
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
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
InboxAssignmentPolicy.where(inbox: inbox).destroy_all
|
||||
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
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
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account])
|
||||
end
|
||||
|
||||
@@ -90,11 +90,11 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
# Create multiple accounts
|
||||
5.times do |_i|
|
||||
acc = create(:account)
|
||||
acc.enable_features('assignment_v2')
|
||||
acc.save!
|
||||
inb = create(:inbox, account: acc, enable_auto_assignment: true)
|
||||
policy = create(:assignment_policy, account: acc)
|
||||
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
|
||||
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
|
||||
|
||||
inbox_relation = instance_double(ActiveRecord::Relation)
|
||||
allow(acc).to receive(:inboxes).and_return(inbox_relation)
|
||||
|
||||
@@ -93,30 +93,6 @@ describe Linear do
|
||||
end
|
||||
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
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
@@ -237,18 +213,6 @@ describe Linear do
|
||||
let(:title) { 'Title' }
|
||||
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
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
@@ -368,18 +332,6 @@ describe Linear do
|
||||
context 'when querying issues' do
|
||||
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
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
|
||||
@@ -74,11 +74,10 @@ describe Webhooks::Trigger do
|
||||
|
||||
context 'when webhook type is agent bot' do
|
||||
let(:webhook_type) { :agent_bot_webhook }
|
||||
let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) }
|
||||
let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) }
|
||||
|
||||
it 'reopens conversation and enqueues activity message if pending' do
|
||||
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)
|
||||
.with(
|
||||
@@ -93,11 +92,11 @@ describe Webhooks::Trigger do
|
||||
perform_enqueued_jobs do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
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.content).to eq(agent_bot_error_content)
|
||||
end
|
||||
@@ -119,52 +118,9 @@ describe Webhooks::Trigger do
|
||||
end.not_to(change { message.reload.status })
|
||||
|
||||
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'keeps conversation pending when keep_pending_on_bot_failure setting is enabled' do
|
||||
account.update(keep_pending_on_bot_failure: true)
|
||||
payload = { event: 'message_created', id: pending_message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
|
||||
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
|
||||
expect(pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'reopens conversation when keep_pending_on_bot_failure setting is disabled' do
|
||||
account.update(keep_pending_on_bot_failure: false)
|
||||
payload = { event: 'message_created', id: pending_message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end.not_to(change { pending_message.reload.status })
|
||||
|
||||
expect(pending_conversation.reload.status).to eq('open')
|
||||
|
||||
activity_message = pending_conversation.reload.messages.order(:created_at).last
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to eq(agent_bot_error_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -178,85 +178,5 @@ RSpec.describe MailPresenter do
|
||||
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
|
||||
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
|
||||
|
||||
@@ -10,9 +10,8 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
# Enable assignment_v2 feature for the account (basic assignment features)
|
||||
account.enable_features('assignment_v2')
|
||||
account.save!
|
||||
# Enable assignment_v2 feature for the account
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
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)
|
||||
end
|
||||
|
||||
it 'still tracks the assignment with default window' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
|
||||
it 'does not track the assignment' do
|
||||
expect(Redis::Alfred).not_to receive(:set)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,40 +111,6 @@ describe MessageTemplates::HookExecutionService do
|
||||
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
|
||||
it 'does not call any template hooks' do
|
||||
contact = create(:contact)
|
||||
|
||||
Reference in New Issue
Block a user