Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fbb93698 | ||
|
|
38d174bda6 | ||
|
|
7ca5d365ae | ||
|
|
6be85a40f7 | ||
|
|
922c989574 | ||
|
|
59928bd85c | ||
|
|
46c55a53bd | ||
|
|
176e09cde7 | ||
|
|
b09c6b4aac | ||
|
|
96e18d74c5 | ||
|
|
311093f0fb | ||
|
|
54514d9d21 | ||
|
|
4633d2a179 | ||
|
|
73c2690526 | ||
|
|
25b545200e | ||
|
|
23c35c8886 | ||
|
|
3f801fda2c | ||
|
|
ea7c5798ff | ||
|
|
1271459874 | ||
|
|
81098b02a8 | ||
|
|
18d6a51d6b | ||
|
|
9c9c1c301d | ||
|
|
5fb40e0b2e | ||
|
|
9ba51a3fd4 | ||
|
|
05d83ce704 | ||
|
|
36b7a86bf7 | ||
|
|
e6d336f727 | ||
|
|
feff61755f | ||
|
|
0c4e21bc6a | ||
|
|
a582b8284d | ||
|
|
bbcbc6d52b | ||
|
|
28e837e8f7 | ||
|
|
f4ea90c2dc | ||
|
|
1d9a28ad64 | ||
|
|
f36a3a2e19 | ||
|
|
a14df55210 | ||
|
|
b3633d5c08 | ||
|
|
b52caf0341 | ||
|
|
e37d61f836 | ||
|
|
10673a8d68 | ||
|
|
d488b8ced6 | ||
|
|
0381bdac96 | ||
|
|
87fee33039 | ||
|
|
fe68542547 | ||
|
|
2e1529c755 | ||
|
|
eb20e39b11 | ||
|
|
b771652656 | ||
|
|
e6d720082c | ||
|
|
d9a0d4eee0 | ||
|
|
2a0f2a8b1e | ||
|
|
aedb59ea67 | ||
|
|
4e2abbaa3d | ||
|
|
9db096f046 | ||
|
|
59b2679641 | ||
|
|
b1d26887f0 | ||
|
|
89a748eef6 | ||
|
|
c00230c8af | ||
|
|
2f6d581384 | ||
|
|
468ef34f85 | ||
|
|
7f4005cdcc | ||
|
|
57fdfe26f0 | ||
|
|
88ba4e4562 | ||
|
|
394ba4ff73 | ||
|
|
c0d1efa0ad | ||
|
|
dfc80e6f20 | ||
|
|
187b435eb9 | ||
|
|
1fce8d1183 | ||
|
|
c51d2d3c93 | ||
|
|
4450bebf35 | ||
|
|
b3ce7bd278 | ||
|
|
345a212447 | ||
|
|
83450b7c24 | ||
|
|
b58ec24d81 | ||
|
|
5e799e7e6b | ||
|
|
01ad380822 | ||
|
|
84d2c0fd25 | ||
|
|
87212d3b6e | ||
|
|
4ca6ec1812 | ||
|
|
4452d84097 | ||
|
|
9a81927154 | ||
|
|
daed6eb1e5 | ||
|
|
673669cd25 | ||
|
|
bdd5a77f45 | ||
|
|
92150cb7c8 | ||
|
|
e52becbc40 | ||
|
|
7f3d4aaaa7 | ||
|
|
7748ef2787 | ||
|
|
f2d9895597 | ||
|
|
db4e8588cd | ||
|
|
0c05599282 | ||
|
|
00e1f50458 | ||
|
|
ee89943aa2 | ||
|
|
9dc6b94537 | ||
|
|
e9816f8d3d | ||
|
|
51d1c501cf | ||
|
|
1539a8bc36 | ||
|
|
ffa8c0ce8a | ||
|
|
4762edcba3 | ||
|
|
1bd2da4832 | ||
|
|
b923bfecca | ||
|
|
46e758cc84 | ||
|
|
9165cab1ec | ||
|
|
bda09cc064 | ||
|
|
408cc4dd3f | ||
|
|
c6b636baee | ||
|
|
bc24d1f02c | ||
|
|
62a7cfa188 | ||
|
|
c4fa5cd4d9 | ||
|
|
d40fa27289 | ||
|
|
c789ef555c | ||
|
|
0674edf81b | ||
|
|
9362d126aa | ||
|
|
35797bd393 | ||
|
|
68ff52c432 | ||
|
|
5d2afc065f | ||
|
|
cd8f245483 | ||
|
|
735979c834 | ||
|
|
5cd6996d5e | ||
|
|
ff49996950 | ||
|
|
14417ef14f | ||
|
|
1393ad5143 | ||
|
|
4f262fc041 | ||
|
|
aa639dd78e | ||
|
|
aa9c0b9f6b | ||
|
|
526d6e2a0a | ||
|
|
96746cc592 |
@@ -94,3 +94,4 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
CLAUDE.local.md
|
||||
|
||||
@@ -179,7 +179,8 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.2.1'
|
||||
gem 'ai-agents', '>= 0.4.3'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
|
||||
+5
-3
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.2.1)
|
||||
ai-agents (0.4.3)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
@@ -720,7 +720,7 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.3.1)
|
||||
ruby_llm (1.5.1)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -729,6 +729,7 @@ GEM
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.1.0)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -910,7 +911,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.2.1)
|
||||
ai-agents (>= 0.4.3)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1004,6 +1005,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
|
||||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
|
||||
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
|
||||
end
|
||||
|
||||
def create
|
||||
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
|
||||
|
||||
if @assignment_policy.save
|
||||
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
|
||||
else
|
||||
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @assignment_policy.update(assignment_policy_params)
|
||||
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
|
||||
else
|
||||
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @assignment_policy.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_policy
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:id])
|
||||
end
|
||||
|
||||
def assignment_policy_params
|
||||
params.require(:assignment_policy).permit(
|
||||
:name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_assignment_policy(policy)
|
||||
{
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
description: policy.description,
|
||||
assignment_order: policy.assignment_order,
|
||||
conversation_priority: policy.conversation_priority,
|
||||
fair_distribution_limit: policy.fair_distribution_limit,
|
||||
fair_distribution_window: policy.fair_distribution_window,
|
||||
enabled: policy.enabled,
|
||||
inbox_count: policy.inboxes.count,
|
||||
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
created_at: policy.created_at,
|
||||
updated_at: policy.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_assignment_policies(policies)
|
||||
policies.map { |policy| serialize_assignment_policy(policy) }
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(AssignmentPolicy)
|
||||
end
|
||||
end
|
||||
@@ -1,81 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :check_authorization
|
||||
|
||||
def show
|
||||
@inbox_assignment_policy = @inbox.inbox_assignment_policy
|
||||
|
||||
if @inbox_assignment_policy
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
inbox_assignment_policy: nil,
|
||||
message: 'No assignment policy assigned to this inbox'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
# Remove existing assignment if any
|
||||
@inbox.inbox_assignment_policy&.destroy
|
||||
|
||||
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
|
||||
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
|
||||
|
||||
if @inbox_assignment_policy.save
|
||||
render json: {
|
||||
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
|
||||
}, status: :created
|
||||
else
|
||||
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox_assignment_policy = @inbox.inbox_assignment_policy
|
||||
|
||||
if @inbox_assignment_policy
|
||||
if @inbox_assignment_policy.destroy
|
||||
head :ok
|
||||
else
|
||||
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(@inbox, :update?)
|
||||
end
|
||||
|
||||
def serialize_inbox_assignment_policy(inbox_assignment_policy)
|
||||
{
|
||||
id: inbox_assignment_policy.id,
|
||||
inbox_id: inbox_assignment_policy.inbox_id,
|
||||
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
|
||||
assignment_policy: {
|
||||
id: inbox_assignment_policy.assignment_policy.id,
|
||||
name: inbox_assignment_policy.assignment_policy.name,
|
||||
description: inbox_assignment_policy.assignment_policy.description,
|
||||
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
|
||||
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
|
||||
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
|
||||
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
|
||||
enabled: inbox_assignment_policy.assignment_policy.enabled
|
||||
},
|
||||
created_at: inbox_assignment_policy.created_at,
|
||||
updated_at: inbox_assignment_policy.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
+5
-7
@@ -38,13 +38,11 @@ const handleClose = () => emit('close');
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||
>
|
||||
<div class="p-6 flex flex-col gap-6">
|
||||
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script>
|
||||
/* eslint no-console: 0 */
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
export default {
|
||||
mixins: [globalConfigMixin],
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
|
||||
@@ -19,14 +19,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
||||
<div
|
||||
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
||||
>
|
||||
<fluent-icon
|
||||
class="ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||
:icon="computedInboxClass"
|
||||
size="12"
|
||||
/>
|
||||
<span class="truncate">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
{{ inbox.name }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import router from '../../../routes';
|
||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
@@ -14,224 +14,257 @@ import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
selected: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
});
|
||||
export default {
|
||||
components: {
|
||||
CardLabels,
|
||||
InboxName,
|
||||
Thumbnail,
|
||||
ConversationContextMenu,
|
||||
TimeAgo,
|
||||
MessagePreview,
|
||||
PriorityMark,
|
||||
SLACardLabel,
|
||||
ContextMenu,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
activeLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
hideInboxName: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideThumbnail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContextMenu: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowedContextMenuOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
hovered: false,
|
||||
showContextMenu: false,
|
||||
contextMenu: {
|
||||
x: null,
|
||||
y: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
inboxesList: 'inboxes/getInboxes',
|
||||
activeInbox: 'getSelectedInbox',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
chatMetadata() {
|
||||
return this.chat.meta || {};
|
||||
},
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
assignee() {
|
||||
return this.chatMetadata.assignee || {};
|
||||
},
|
||||
|
||||
const router = useRouter();
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.chatMetadata.sender.id
|
||||
);
|
||||
},
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
y: null,
|
||||
});
|
||||
isActiveChat() {
|
||||
return this.currentChat.id === this.chat.id;
|
||||
},
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const contactById = useMapGetter('contacts/getContact');
|
||||
const inboxById = useMapGetter('inboxes/getInbox');
|
||||
unreadCount() {
|
||||
return this.chat.unread_count;
|
||||
},
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
hasUnread() {
|
||||
return this.unreadCount > 0;
|
||||
},
|
||||
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
isInboxNameVisible() {
|
||||
return !this.activeInbox;
|
||||
},
|
||||
|
||||
const currentContact = computed(() => {
|
||||
return contactById.value(chatMetadata.value.sender.id);
|
||||
});
|
||||
lastMessageInChat() {
|
||||
return getLastMessage(this.chat);
|
||||
},
|
||||
|
||||
const isActiveChat = computed(() => {
|
||||
return currentChat.value.id === props.chat.id;
|
||||
});
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
return stateInbox;
|
||||
},
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
showInboxName() {
|
||||
return (
|
||||
!this.hideInboxName &&
|
||||
this.isInboxNameVisible &&
|
||||
this.inboxesList.length > 1
|
||||
);
|
||||
},
|
||||
inboxName() {
|
||||
const stateInbox = this.inbox;
|
||||
return stateInbox.name || '';
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
conversationPath() {
|
||||
const { activeInbox, chat } = this;
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
const path = this.conversationPath;
|
||||
if (!path) return;
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
// Handle Ctrl/Cmd + Click for new tab
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
// Skip if already active
|
||||
if (this.isActiveChat) return;
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const inbox = computed(() => {
|
||||
const { inbox_id: inboxId } = props.chat;
|
||||
const stateInbox = inboxById.value(inboxId);
|
||||
return stateInbox;
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => {
|
||||
return (
|
||||
!props.hideInboxName &&
|
||||
isInboxNameVisible.value &&
|
||||
inboxesList.value.length > 1
|
||||
);
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
showInboxName.value ||
|
||||
(props.showAssignee && assignee.value.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
});
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const showLabelsSection = computed(() => {
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
});
|
||||
|
||||
const messagePreviewClass = computed(() => {
|
||||
return [
|
||||
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
|
||||
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
|
||||
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
|
||||
];
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
// Handle Ctrl/Cmd + Click for new tab
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already active
|
||||
if (isActiveChat.value) return;
|
||||
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
router.push({ path });
|
||||
},
|
||||
onThumbnailHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
},
|
||||
onThumbnailLeave() {
|
||||
this.hovered = false;
|
||||
},
|
||||
onSelectConversation(checked) {
|
||||
const action = checked ? 'selectConversation' : 'deSelectConversation';
|
||||
this.$emit(action, this.chat.id, this.inbox.id);
|
||||
},
|
||||
openContextMenu(e) {
|
||||
if (!this.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
this.$emit('contextMenuToggle', true);
|
||||
this.contextMenu.x = e.pageX || e.clientX;
|
||||
this.contextMenu.y = e.pageY || e.clientY;
|
||||
this.showContextMenu = true;
|
||||
},
|
||||
closeContextMenu() {
|
||||
this.$emit('contextMenuToggle', false);
|
||||
this.showContextMenu = false;
|
||||
this.contextMenu.x = null;
|
||||
this.contextMenu.y = null;
|
||||
},
|
||||
onUpdateConversation(status, snoozedUntil) {
|
||||
this.closeContextMenu();
|
||||
this.$emit(
|
||||
'updateConversationStatus',
|
||||
this.chat.id,
|
||||
status,
|
||||
snoozedUntil
|
||||
);
|
||||
},
|
||||
async onAssignAgent(agent) {
|
||||
this.$emit('assignAgent', agent, [this.chat.id]);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async onAssignLabel(label) {
|
||||
this.$emit('assignLabel', [label.title], [this.chat.id]);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async onAssignTeam(team) {
|
||||
this.$emit('assignTeam', team, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async markAsUnread() {
|
||||
this.$emit('markAsUnread', this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async markAsRead() {
|
||||
this.$emit('markAsRead', this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async assignPriority(priority) {
|
||||
this.$emit('assignPriority', priority, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async deleteConversation() {
|
||||
this.$emit('deleteConversation', this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||
isActiveChat,
|
||||
'bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
'unread-chat': hasUnread,
|
||||
'has-inbox-name': showInboxName,
|
||||
'conversation-selected': selected,
|
||||
}"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
@@ -243,14 +276,13 @@ const deleteConversation = () => {
|
||||
>
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-20 backdrop-blur-[2px]"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="!m-0 cursor-pointer"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
@@ -258,42 +290,30 @@ const deleteConversation = () => {
|
||||
<Thumbnail
|
||||
v-if="!hideThumbnail"
|
||||
:src="currentContact.thumbnail"
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="32px"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
|
||||
>
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
:class="{
|
||||
'ltr:ml-2 rtl:mr-2': !compact,
|
||||
'mx-2': compact,
|
||||
}"
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
>
|
||||
<div class="flex justify-between conversation-card--meta">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" />
|
||||
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
|
||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
||||
<PriorityMark :priority="chat.priority" />
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
|
||||
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
|
||||
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
@@ -301,27 +321,24 @@ const deleteConversation = () => {
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
|
||||
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
||||
icon="info"
|
||||
/>
|
||||
<span class="mx-0.5">
|
||||
<span>
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
|
||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
@@ -329,17 +346,12 @@ const deleteConversation = () => {
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
:conversation-labels="chat.labels"
|
||||
class="mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
@@ -372,3 +384,55 @@ const deleteConversation = () => {
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation {
|
||||
&.unread-chat {
|
||||
.unread {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
@apply pl-0;
|
||||
|
||||
.conversation-card--meta {
|
||||
@apply ltr:pr-4 rtl:pl-4;
|
||||
}
|
||||
|
||||
.conversation--details {
|
||||
@apply rounded-sm ml-0 pl-5 pr-2;
|
||||
}
|
||||
}
|
||||
|
||||
&::v-deep .user-thumbnail-box {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
&.conversation-selected {
|
||||
@apply bg-n-slate-2 dark:bg-n-slate-3;
|
||||
}
|
||||
|
||||
&.has-inbox-name {
|
||||
&::v-deep .user-thumbnail-box {
|
||||
@apply mt-8;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
@apply mt-8;
|
||||
}
|
||||
|
||||
.conversation--meta {
|
||||
@apply mt-4;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply m-0 cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -263,7 +263,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__container {
|
||||
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
}
|
||||
|
||||
.bulk-action__panel {
|
||||
|
||||
@@ -3,7 +3,7 @@ import WidgetHead from './WidgetHead.vue';
|
||||
import WidgetBody from './WidgetBody.vue';
|
||||
import WidgetFooter from './WidgetFooter.vue';
|
||||
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
WidgetFooter,
|
||||
InputRadioGroup,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
props: {
|
||||
welcomeHeading: {
|
||||
type: String,
|
||||
@@ -56,12 +57,6 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
widgetScreens: [
|
||||
@@ -164,8 +159,9 @@ export default {
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
replaceInstallationName(
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
|
||||
useInstallationName(
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
|
||||
@@ -61,8 +61,8 @@ export default {
|
||||
:hide-inbox-name="false"
|
||||
hide-thumbnail
|
||||
enable-context-menu
|
||||
compact
|
||||
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
|
||||
class="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,4 +75,12 @@ export default {
|
||||
.no-label-message {
|
||||
@apply text-n-slate-11 mb-4;
|
||||
}
|
||||
|
||||
::v-deep .conversation {
|
||||
@apply pr-0;
|
||||
|
||||
.conversation--details {
|
||||
@apply pl-2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,19 +3,14 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
|
||||
import router from '../../../index';
|
||||
import PageHeader from '../SettingsSubPageHeader.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChannelItem,
|
||||
PageHeader,
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
data() {
|
||||
return {
|
||||
enabledFeatures: {},
|
||||
@@ -74,7 +69,12 @@ export default {
|
||||
<PageHeader
|
||||
class="max-w-4xl"
|
||||
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
|
||||
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
|
||||
:header-content="
|
||||
useInstallationName(
|
||||
$t('INBOX_MGMT.ADD.AUTH.DESC'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
@@ -34,7 +29,10 @@ export default {
|
||||
items() {
|
||||
return this.createFlowSteps.map(item => ({
|
||||
...item,
|
||||
body: this.replaceInstallationName(item.body),
|
||||
body: this.useInstallationName(
|
||||
item.body,
|
||||
this.globalConfig.installationName
|
||||
),
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
|
||||
import { mapGetters } from 'vuex';
|
||||
import ChannelApi from '../../../../../api/channels';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import router from '../../../../index';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
@@ -22,12 +22,11 @@ export default {
|
||||
PageHeader,
|
||||
NextButton,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
accountId,
|
||||
replaceInstallationName,
|
||||
v$: useVuelidate(),
|
||||
};
|
||||
},
|
||||
@@ -67,6 +66,9 @@ export default {
|
||||
getSelectablePages() {
|
||||
return this.pageList.filter(item => !item.exists);
|
||||
},
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
@@ -221,7 +223,12 @@ export default {
|
||||
/>
|
||||
</a>
|
||||
<p class="py-6">
|
||||
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
|
||||
{{
|
||||
useInstallationName(
|
||||
$t('INBOX_MGMT.ADD.FB.HELP'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -242,7 +249,10 @@ export default {
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.DETAILS.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName($t('INBOX_MGMT.ADD.DETAILS.DESC'))
|
||||
useInstallationName(
|
||||
$t('INBOX_MGMT.ADD.DETAILS.DESC'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
|
||||
export default {
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
return {
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import DashboardAppModal from './DashboardAppModal.vue';
|
||||
import DashboardAppsRow from './DashboardAppsRow.vue';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -13,6 +14,7 @@ export default {
|
||||
DashboardAppsRow,
|
||||
NextButton,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script setup>
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import IntegrationItem from './IntegrationItem.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const uiFlags = getters['integrations/getUIFlags'];
|
||||
|
||||
@@ -29,9 +27,7 @@ onMounted(() => {
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
:description="
|
||||
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
|
||||
"
|
||||
:description="$t('INTEGRATION_SETTINGS.DESCRIPTION')"
|
||||
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
|
||||
feature-name="integrations"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -26,11 +26,11 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
|
||||
const openDeletePopup = () => {
|
||||
if (dialogRef.value) {
|
||||
@@ -82,7 +82,12 @@ const confirmDeletion = () => {
|
||||
{{ integrationName }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-sm leading-6">
|
||||
{{ replaceInstallationName(integrationDescription) }}
|
||||
{{
|
||||
useInstallationName(
|
||||
integrationDescription,
|
||||
globalConfig.installationName
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -28,9 +28,9 @@ const props = defineProps({
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const accountId = getters.getCurrentAccountId;
|
||||
const globalConfig = getters['globalConfig/get'];
|
||||
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const integrationStatus = computed(() =>
|
||||
props.enabled
|
||||
@@ -80,7 +80,7 @@ const actionURL = computed(() =>
|
||||
</router-link>
|
||||
</div>
|
||||
<p class="text-n-slate-11">
|
||||
{{ replaceInstallationName(description) }}
|
||||
{{ useInstallationName(description, globalConfig.installationName) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import Integration from './Integration.vue';
|
||||
import IntegrationHelpText from './IntegrationHelpText.vue';
|
||||
|
||||
@@ -7,6 +8,8 @@ export default {
|
||||
Integration,
|
||||
IntegrationHelpText,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
|
||||
props: {
|
||||
integrationId: {
|
||||
type: [String, Number],
|
||||
|
||||
+9
-3
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -18,7 +18,6 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const selectedChannelId = ref('');
|
||||
const availableChannels = ref([]);
|
||||
@@ -30,9 +29,16 @@ const errorDescription = computed(() => {
|
||||
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
|
||||
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
|
||||
});
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
|
||||
const formattedErrorMessage = computed(() => {
|
||||
return formatMessage(replaceInstallationName(errorDescription.value), false);
|
||||
return formatMessage(
|
||||
useInstallationName(
|
||||
errorDescription.value,
|
||||
globalConfig.value.installationName
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
const fetchChannels = async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import NewWebhook from './NewWebHook.vue';
|
||||
import EditWebhook from './EditWebHook.vue';
|
||||
@@ -18,10 +17,6 @@ export default {
|
||||
EditWebhook,
|
||||
WebhookRow,
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return { replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
@@ -104,7 +99,7 @@ export default {
|
||||
<BaseSettingsHeader
|
||||
v-if="integration.name"
|
||||
:title="integration.name"
|
||||
:description="replaceInstallationName(integration.description)"
|
||||
:description="integration.description"
|
||||
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
|
||||
feature-name="webhook"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
|
||||
+7
-8
@@ -1,25 +1,21 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import WebhookForm from './WebhookForm.vue';
|
||||
|
||||
export default {
|
||||
components: { WebhookForm },
|
||||
mixins: [globalConfigMixin],
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
}),
|
||||
},
|
||||
@@ -47,7 +43,10 @@ export default {
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
useInstallationName(
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
|
||||
@@ -3,10 +3,10 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import UserProfilePicture from './UserProfilePicture.vue';
|
||||
import UserBasicDetails from './UserBasicDetails.vue';
|
||||
import MessageSignature from './MessageSignature.vue';
|
||||
@@ -37,17 +37,16 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||
const { currentFontSize, updateFontSize } = useFontSize();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return {
|
||||
currentFontSize,
|
||||
updateFontSize,
|
||||
isEditorHotKeyEnabled,
|
||||
updateUISettings,
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -216,11 +215,7 @@ export default {
|
||||
</div>
|
||||
<FormSection
|
||||
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
|
||||
:description="
|
||||
replaceInstallationName(
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')
|
||||
)
|
||||
"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')"
|
||||
>
|
||||
<FontSize
|
||||
:value="currentFontSize"
|
||||
@@ -293,7 +288,10 @@ export default {
|
||||
<FormSection
|
||||
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
|
||||
:description="
|
||||
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
|
||||
useInstallationName(
|
||||
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
"
|
||||
>
|
||||
<AccessToken
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
const {
|
||||
LOGO_THUMBNAIL: logoThumbnail,
|
||||
@@ -8,18 +8,13 @@ const {
|
||||
} = window.globalConfig || {};
|
||||
|
||||
export default {
|
||||
mixins: [globalConfigMixin],
|
||||
props: {
|
||||
disableBranding: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
globalConfig: {
|
||||
@@ -66,7 +61,7 @@ export default {
|
||||
:src="globalConfig.logoThumbnail"
|
||||
/>
|
||||
<span>
|
||||
{{ replaceInstallationName($t('POWERED_BY')) }}
|
||||
{{ useInstallationName($t('POWERED_BY'), globalConfig.brandName) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useBranding } from '../useBranding';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
// Mock the store composable
|
||||
vi.mock('dashboard/composables/store.js', () => ({
|
||||
useMapGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useBranding', () => {
|
||||
let mockGlobalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobalConfig = {
|
||||
value: {
|
||||
installationName: 'MyCompany',
|
||||
},
|
||||
};
|
||||
|
||||
useMapGetter.mockReturnValue(mockGlobalConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('replaceInstallationName', () => {
|
||||
it('should replace "Chatwoot" with installation name when both text and installation name are provided', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to MyCompany');
|
||||
});
|
||||
|
||||
it('should replace multiple occurrences of "Chatwoot"', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName(
|
||||
'Chatwoot is great! Use Chatwoot today.'
|
||||
);
|
||||
|
||||
expect(result).toBe('MyCompany is great! Use MyCompany today.');
|
||||
});
|
||||
|
||||
it('should return original text when installation name is not provided', () => {
|
||||
mockGlobalConfig.value = {};
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to Chatwoot');
|
||||
});
|
||||
|
||||
it('should return original text when globalConfig is not available', () => {
|
||||
mockGlobalConfig.value = undefined;
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to Chatwoot');
|
||||
});
|
||||
|
||||
it('should return original text when text is empty or null', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
expect(replaceInstallationName('')).toBe('');
|
||||
expect(replaceInstallationName(null)).toBe(null);
|
||||
expect(replaceInstallationName(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle text without "Chatwoot" gracefully', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to our platform');
|
||||
|
||||
expect(result).toBe('Welcome to our platform');
|
||||
});
|
||||
|
||||
it('should be case-sensitive for "Chatwoot"', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName(
|
||||
'Welcome to chatwoot and CHATWOOT'
|
||||
);
|
||||
|
||||
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
|
||||
});
|
||||
|
||||
it('should handle special characters in installation name', () => {
|
||||
mockGlobalConfig.value = {
|
||||
installationName: 'My-Company & Co.',
|
||||
};
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to My-Company & Co.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Composable for branding-related utilities
|
||||
* Provides methods to customize text with installation-specific branding
|
||||
*/
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
export function useBranding() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
/**
|
||||
* Replaces "Chatwoot" in text with the installation name from global config
|
||||
* @param {string} text - The text to process
|
||||
* @returns {string} - Text with "Chatwoot" replaced by installation name
|
||||
*/
|
||||
const replaceInstallationName = text => {
|
||||
if (!text) return text;
|
||||
|
||||
const installationName = globalConfig.value?.installationName;
|
||||
if (!installationName) return text;
|
||||
|
||||
return text.replace(/Chatwoot/g, installationName);
|
||||
};
|
||||
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const useInstallationName = (str, installationName) => {
|
||||
if (str && installationName) {
|
||||
return str.replace(/Chatwoot/g, installationName);
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
useInstallationName,
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required, minLength, email } from '@vuelidate/validators';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import FormInput from '../../../../components/Form/Input.vue';
|
||||
import { resetPassword } from '../../../../api/auth';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { FormInput, NextButton },
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return { v$: useVuelidate(), replaceInstallationName };
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -23,6 +24,9 @@ export default {
|
||||
error: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
credentials: {
|
||||
@@ -78,7 +82,12 @@ export default {
|
||||
<p
|
||||
class="mb-4 text-sm font-normal leading-6 tracking-normal text-n-slate-11"
|
||||
>
|
||||
{{ replaceInstallationName($t('RESET_PASSWORD.DESCRIPTION')) }}
|
||||
{{
|
||||
useInstallationName(
|
||||
$t('RESET_PASSWORD.DESCRIPTION'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="space-y-5">
|
||||
<FormInput
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import SignupForm from './components/Signup/Form.vue';
|
||||
import Testimonials from './components/Testimonials/Index.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
@@ -11,10 +11,7 @@ export default {
|
||||
Spinner,
|
||||
Testimonials,
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return { replaceInstallationName };
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
data() {
|
||||
return { isLoading: false };
|
||||
},
|
||||
@@ -64,7 +61,12 @@ export default {
|
||||
<div class="px-1 text-sm text-n-slate-12">
|
||||
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
|
||||
<router-link class="text-link text-n-brand" to="/app/login">
|
||||
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
|
||||
{{
|
||||
useInstallationName(
|
||||
$t('LOGIN.TITLE'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
}}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, email } from '@vuelidate/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import FormInput from '../../../../../components/Form/Input.vue';
|
||||
@@ -19,6 +20,7 @@ export default {
|
||||
NextButton,
|
||||
VueHcaptcha,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
|
||||
@@ -8,7 +8,8 @@ import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
// mixins
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
// components
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
@@ -30,6 +31,7 @@ export default {
|
||||
Spinner,
|
||||
NextButton,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
props: {
|
||||
ssoAuthToken: { type: String, default: '' },
|
||||
ssoAccountId: { type: String, default: '' },
|
||||
@@ -38,11 +40,7 @@ export default {
|
||||
authError: { type: String, default: '' },
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
v$: useVuelidate(),
|
||||
};
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -184,7 +182,9 @@ export default {
|
||||
class="hidden w-auto h-8 mx-auto dark:block"
|
||||
/>
|
||||
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
|
||||
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
|
||||
{{
|
||||
useInstallationName($t('LOGIN.TITLE'), globalConfig.installationName)
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="showSignupLink" class="mt-3 text-sm text-center text-n-slate-11">
|
||||
{{ $t('COMMON.OR') }}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::AssignmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox_id: nil, conversation_id: nil)
|
||||
if conversation_id
|
||||
assign_single_conversation(conversation_id)
|
||||
elsif inbox_id
|
||||
assign_inbox_conversations(inbox_id)
|
||||
else
|
||||
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_single_conversation(conversation_id)
|
||||
conversation = Conversation.find_by(id: conversation_id)
|
||||
return unless conversation
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
|
||||
service.perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
def assign_inbox_conversations(inbox_id)
|
||||
inbox = Inbox.find_by(id: inbox_id)
|
||||
return unless inbox
|
||||
return unless inbox.assignment_v2_enabled?
|
||||
|
||||
service = AssignmentV2::AssignmentService.new(inbox: inbox)
|
||||
assigned_count = service.perform_bulk_assignment
|
||||
|
||||
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ class AutomationRuleListener < BaseListener
|
||||
end
|
||||
|
||||
def conversation_created(event)
|
||||
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
|
||||
return if performed_by_automation?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
@@ -34,7 +34,7 @@ class AutomationRuleListener < BaseListener
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
|
||||
return if performed_by_automation?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
@@ -87,13 +87,8 @@ class AutomationRuleListener < BaseListener
|
||||
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
|
||||
end
|
||||
|
||||
def ignore_auto_reply_event?(event)
|
||||
conversation = event.data[:conversation]
|
||||
conversation.additional_attributes['auto_reply'].present?
|
||||
end
|
||||
|
||||
def ignore_message_created_event?(event)
|
||||
message = event.data[:message]
|
||||
performed_by_automation?(event) || message.activity? || message.auto_reply_email?
|
||||
performed_by_automation?(event) || message.activity?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,7 +84,6 @@ class Imap::ImapMailbox
|
||||
additional_attributes: {
|
||||
source: 'email',
|
||||
in_reply_to: in_reply_to,
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
|
||||
@@ -8,6 +8,13 @@ module IncomingEmailValidityHelper
|
||||
# This can happen in cases like bounce emails for invalid contact email address
|
||||
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
|
||||
|
||||
# Process bounced emails, as regular emails
|
||||
return true if @processed_mail.bounced?
|
||||
|
||||
# we skip processing auto reply emails like delivery status notifications
|
||||
# out of office replies, etc.
|
||||
return false if auto_reply_email?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
@@ -17,4 +24,13 @@ module IncomingEmailValidityHelper
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def auto_reply_email?
|
||||
if @processed_mail.auto_reply?
|
||||
Rails.logger.info "is_auto_reply? : #{processed_mail.auto_reply?}"
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,7 +70,6 @@ class SupportMailbox < ApplicationMailbox
|
||||
additional_attributes: {
|
||||
in_reply_to: in_reply_to,
|
||||
source: 'email',
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# assignment_order :integer default("round_robin"), not null
|
||||
# conversation_priority :integer default("earliest_created"), not null
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# fair_distribution_limit :integer default(10), not null
|
||||
# fair_distribution_window :integer default(3600), not null
|
||||
# name :string(255) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_assignment_policies_on_account_id (account_id)
|
||||
# index_assignment_policies_on_enabled (enabled)
|
||||
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
|
||||
#
|
||||
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Enums
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 }
|
||||
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
|
||||
|
||||
# Associations
|
||||
belongs_to :account
|
||||
has_many :inbox_assignment_policies, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_assignment_policies
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :name, length: { maximum: 255 }
|
||||
validates :description, length: { maximum: 1000 }
|
||||
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
||||
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
|
||||
validates :assignment_order, inclusion: { in: assignment_orders.keys }
|
||||
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
|
||||
|
||||
# Validate balanced assignment is only available for enterprise
|
||||
validate :validate_balanced_assignment_enterprise_only
|
||||
|
||||
# Server-side validation to prevent bypass
|
||||
before_save :enforce_enterprise_features
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :disabled, -> { where(enabled: false) }
|
||||
|
||||
# Callbacks
|
||||
after_update_commit :clear_assignment_caches
|
||||
after_destroy :clear_assignment_caches
|
||||
|
||||
def can_use_balanced_assignment?
|
||||
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
assignment_order: assignment_order,
|
||||
conversation_priority: conversation_priority,
|
||||
fair_distribution_limit: fair_distribution_limit,
|
||||
fair_distribution_window: fair_distribution_window,
|
||||
enabled: enabled
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_balanced_assignment_enterprise_only
|
||||
return unless balanced?
|
||||
return if can_use_balanced_assignment?
|
||||
|
||||
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
|
||||
def enforce_enterprise_features
|
||||
# Server-side enforcement to prevent API bypass
|
||||
return unless balanced? && !can_use_balanced_assignment?
|
||||
|
||||
# Force to round_robin if enterprise not available
|
||||
self.assignment_order = 'round_robin'
|
||||
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
|
||||
end
|
||||
|
||||
def clear_assignment_caches
|
||||
# Clear Redis caches when policy is updated
|
||||
Rails.cache.delete("assignment_v2:policy:#{id}")
|
||||
inboxes.find_each do |inbox|
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AssignmentV2FeatureFlag
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def assignment_v2_enabled?
|
||||
# Check account-level feature flag
|
||||
return false unless feature_enabled_for_account?
|
||||
|
||||
# Check for any inbox-level overrides
|
||||
return false if inbox_level_override_disabled?
|
||||
|
||||
# Check system-wide killswitch
|
||||
!GlobalConfig.get('assignment_v2_disabled', false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def feature_enabled_for_account?
|
||||
config = GlobalConfig.get('assignment_v2')
|
||||
return false unless config&.dig('enabled')
|
||||
|
||||
# If no account allowlist, enable for all
|
||||
allowed_accounts = config['accounts']
|
||||
return true if allowed_accounts.blank?
|
||||
|
||||
# Check if account is in allowlist
|
||||
allowed_accounts.include?(id)
|
||||
end
|
||||
|
||||
def inbox_level_override_disabled?
|
||||
return false unless respond_to?(:id)
|
||||
|
||||
# Allow per-inbox disabling during migration
|
||||
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
|
||||
end
|
||||
end
|
||||
@@ -14,18 +14,11 @@ module AutoAssignmentHandler
|
||||
return unless conversation_status_changed_to_open?
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.assignment_v2_enabled?
|
||||
# Use Assignment V2 system
|
||||
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
|
||||
else
|
||||
# Use legacy assignment system
|
||||
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
||||
end
|
||||
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
||||
end
|
||||
|
||||
def should_run_auto_assignment?
|
||||
# Check auto assignment is enabled (either legacy or v2)
|
||||
return false unless inbox.auto_assignment_enabled?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = build_online_agents_scope(online_agent_ids)
|
||||
|
||||
# Apply filters
|
||||
apply_agent_filters(scope, options)
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
|
||||
|
||||
available_agents(check_capacity: true).pluck(:user_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def apply_agent_filters(scope, options)
|
||||
# Exclude specific users if requested
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
rate_limiter.within_limits?
|
||||
end
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
|
||||
|
||||
# Filter out agents who are currently on leave
|
||||
on_leave_user_ids = Enterprise::AgentLeave
|
||||
.active
|
||||
.where(account_id: account_id)
|
||||
.pluck(:user_id)
|
||||
|
||||
return inbox_members_scope if on_leave_user_ids.empty?
|
||||
|
||||
inbox_members_scope.where.not(user_id: on_leave_user_ids)
|
||||
end
|
||||
end
|
||||
@@ -1,54 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxChannelTypes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
end
|
||||
end
|
||||
@@ -1,68 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxNameSanitization
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :sanitize_name, unless: :new_record?
|
||||
before_save :ensure_name_present
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return handle_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
return sanitized if sanitized.present?
|
||||
|
||||
email? ? (display_name_from_email || '') : ''
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_blank_name
|
||||
email? ? (display_name_from_email || '') : ''
|
||||
end
|
||||
|
||||
def sanitize_name
|
||||
self.name = apply_sanitization_rules(name) if name.present?
|
||||
end
|
||||
|
||||
def ensure_name_present
|
||||
self.name = default_name_for_blank_name if name.blank?
|
||||
self.name = apply_sanitization_rules(name) if name.present?
|
||||
end
|
||||
|
||||
def default_name_for_blank_name
|
||||
return channel.try(:bot_name) if web_widget?
|
||||
|
||||
readable_name = display_name_from_email if email?
|
||||
readable_name ||= 'Inbox'
|
||||
"#{readable_name} #{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
# Remove forbidden characters and spam-trigger symbols
|
||||
# Keep: letters, numbers, spaces, /'._- and Unicode characters (including emojis)
|
||||
sanitized = name.gsub(/[\\<>@"!#$%&*+=?^`{|}~;:]/, '')
|
||||
# Normalize whitespace
|
||||
sanitized = sanitized.gsub(/\s+/, ' ')
|
||||
# Remove leading and trailing non-word characters (but keep Unicode including emojis)
|
||||
# Use negative lookahead to exclude emoji ranges
|
||||
sanitized = sanitized.gsub(%r{\A[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+|[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+\z}, '')
|
||||
sanitized.strip
|
||||
end
|
||||
|
||||
def display_name_from_email
|
||||
email_address = channel.try(:imap_email) || channel.try(:email)
|
||||
return nil unless email_address
|
||||
|
||||
local_part = email_address.split('@').first
|
||||
return nil unless local_part
|
||||
|
||||
# Convert underscores and hyphens to spaces and capitalize each word
|
||||
local_part.gsub(/[_-]/, ' ').split.map(&:capitalize).join(' ')
|
||||
end
|
||||
end
|
||||
@@ -1,25 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxWebhooks
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
+86
-153
@@ -44,11 +44,6 @@ class Inbox < ApplicationRecord
|
||||
include Avatarable
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include AssignmentV2FeatureFlag
|
||||
include InboxAgentAvailability
|
||||
include InboxChannelTypes
|
||||
include InboxWebhooks
|
||||
include InboxNameSanitization
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -77,10 +72,6 @@ class Inbox < ApplicationRecord
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
|
||||
# Assignment V2 associations
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
|
||||
enum sender_name_type: { friendly: 0, professional: 1 }
|
||||
|
||||
after_destroy :delete_round_robin_agents
|
||||
@@ -106,167 +97,109 @@ class Inbox < ApplicationRecord
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
sanitized = apply_sanitization_rules(name)
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
|
||||
def facebook?
|
||||
channel_type == 'Channel::FacebookPage'
|
||||
end
|
||||
|
||||
def instagram?
|
||||
(facebook? || instagram_direct?) && channel.instagram_id.present?
|
||||
end
|
||||
|
||||
def instagram_direct?
|
||||
channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
def api?
|
||||
channel_type == 'Channel::Api'
|
||||
end
|
||||
|
||||
def email?
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
def twitter?
|
||||
channel_type == 'Channel::TwitterProfile'
|
||||
end
|
||||
|
||||
def whatsapp?
|
||||
channel_type == 'Channel::Whatsapp'
|
||||
end
|
||||
|
||||
def assignable_agents
|
||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||
end
|
||||
|
||||
# Assignment V2 methods
|
||||
def assignment_v2_enabled?
|
||||
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
|
||||
def active_bot?
|
||||
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
|
||||
status: 'enabled').count.positive?
|
||||
end
|
||||
|
||||
def auto_assignment_enabled?
|
||||
if assignment_v2_enabled?
|
||||
assignment_policy.present? && assignment_policy.enabled?
|
||||
else
|
||||
enable_auto_assignment?
|
||||
def inbox_type
|
||||
channel.name
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
name: name
|
||||
}
|
||||
end
|
||||
|
||||
def callback_webhook_url
|
||||
case channel_type
|
||||
when 'Channel::TwilioSms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
|
||||
when 'Channel::Sms'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
|
||||
when 'Channel::Line'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
|
||||
when 'Channel::Whatsapp'
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
# Returns inbox members who are available for assignment
|
||||
# This method performs all filtering upfront at the database level for optimal performance
|
||||
#
|
||||
# Filters applied:
|
||||
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
|
||||
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
|
||||
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
|
||||
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
|
||||
#
|
||||
# @param options [Hash] Additional filter options
|
||||
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
|
||||
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
|
||||
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
|
||||
#
|
||||
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
|
||||
#
|
||||
# @example Get all available agents
|
||||
# inbox.available_agents
|
||||
#
|
||||
# @example Get available agents excluding specific users
|
||||
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
|
||||
#
|
||||
# @example Get available agents without capacity check (faster but less accurate)
|
||||
# inbox.available_agents(check_capacity: false)
|
||||
def available_agents(options = {})
|
||||
options = { check_capacity: true }.merge(options)
|
||||
|
||||
# Get online agent IDs
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
# Base query - only online agents
|
||||
scope = build_online_agents_scope(online_agent_ids)
|
||||
|
||||
# Apply filters
|
||||
apply_agent_filters(scope, options)
|
||||
def member_ids_with_assignment_capacity
|
||||
members.ids
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_online_agents_scope(online_agent_ids)
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
def default_name_for_blank_name
|
||||
email? ? display_name_from_email : ''
|
||||
end
|
||||
|
||||
def apply_agent_filters(scope, options)
|
||||
# Exclude specific users if requested
|
||||
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
|
||||
|
||||
# Apply capacity filtering for enterprise accounts
|
||||
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
|
||||
|
||||
# Apply rate limiting if implemented
|
||||
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
|
||||
|
||||
# Exclude agents who are on leave
|
||||
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
|
||||
|
||||
scope
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
.strip
|
||||
end
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
|
||||
def enterprise_capacity_enabled?
|
||||
defined?(Enterprise) &&
|
||||
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
|
||||
end
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_check_required?
|
||||
|
||||
assignment_counts = fetch_assignment_counts
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
agent_has_capacity?(inbox_member, assignment_counts)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_check_required?
|
||||
defined?(Enterprise::InboxCapacityLimit) &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def fetch_assignment_counts
|
||||
conversations
|
||||
.where(status: :open)
|
||||
.where.not(assignee_id: nil)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
end
|
||||
|
||||
def agent_has_capacity?(inbox_member, assignment_counts)
|
||||
user = inbox_member.user
|
||||
account_user = account.account_users.find_by(user: user)
|
||||
|
||||
return true unless account_user&.agent_capacity_policy_id
|
||||
|
||||
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
|
||||
return true unless capacity_limit&.conversation_limit
|
||||
|
||||
current_count = assignment_counts[user.id] || 0
|
||||
current_count < capacity_limit.conversation_limit
|
||||
end
|
||||
|
||||
def fetch_capacity_limit(policy_id)
|
||||
Enterprise::InboxCapacityLimit
|
||||
.where(agent_capacity_policy_id: policy_id)
|
||||
.find_by(inbox_id: id)
|
||||
end
|
||||
|
||||
def filter_by_rate_limits(inbox_members_scope)
|
||||
# Filter out agents who have exceeded rate limits
|
||||
return inbox_members_scope unless assignment_policy&.enabled?
|
||||
|
||||
# Get IDs of inbox members within rate limits
|
||||
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
|
||||
inbox_member.id if rate_limiter.within_limits?
|
||||
end
|
||||
|
||||
# Return filtered scope maintaining ActiveRecord relation
|
||||
inbox_members_scope.where(id: valid_inbox_member_ids)
|
||||
end
|
||||
|
||||
def filter_agents_on_leave(inbox_members_scope)
|
||||
# Get account users on active leave
|
||||
account_user_ids_on_leave = account.account_users
|
||||
.joins(:leaves)
|
||||
.where(leaves: { status: 'approved' })
|
||||
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
|
||||
.pluck(:id)
|
||||
|
||||
return inbox_members_scope if account_user_ids_on_leave.empty?
|
||||
|
||||
# Exclude inbox members whose account_users are on leave
|
||||
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
|
||||
inbox_members_scope.where.not(user_id: user_ids_on_leave)
|
||||
def display_name_from_email
|
||||
channel.email.split('@').first.parameterize.titleize
|
||||
end
|
||||
|
||||
def dispatch_create_event
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_assignment_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# assignment_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
|
||||
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
|
||||
# unique_inbox_assignment_policy (inbox_id) UNIQUE
|
||||
#
|
||||
|
||||
class InboxAssignmentPolicy < ApplicationRecord
|
||||
include AccountCacheRevalidator
|
||||
|
||||
# Associations
|
||||
belongs_to :inbox
|
||||
belongs_to :assignment_policy
|
||||
|
||||
# Validations
|
||||
validates :inbox_id, uniqueness: true
|
||||
validate :inbox_belongs_to_same_account
|
||||
|
||||
# Delegations
|
||||
delegate :account, to: :inbox
|
||||
delegate :name, :description, :assignment_order, :conversation_priority,
|
||||
:fair_distribution_limit, :fair_distribution_window, :enabled?,
|
||||
to: :assignment_policy, prefix: :policy
|
||||
|
||||
# Callbacks
|
||||
after_commit :clear_inbox_cache
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
|
||||
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
|
||||
|
||||
def webhook_data
|
||||
{
|
||||
id: id,
|
||||
inbox_id: inbox_id,
|
||||
assignment_policy_id: assignment_policy_id,
|
||||
policy: assignment_policy.webhook_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox_belongs_to_same_account
|
||||
return unless inbox && assignment_policy
|
||||
|
||||
return if inbox.account_id == assignment_policy.account_id
|
||||
|
||||
errors.add(:inbox, 'must belong to the same account as the assignment policy')
|
||||
end
|
||||
|
||||
def clear_inbox_cache
|
||||
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
|
||||
update_account_cache
|
||||
end
|
||||
end
|
||||
@@ -195,12 +195,6 @@ class Message < ApplicationRecord
|
||||
true
|
||||
end
|
||||
|
||||
def auto_reply_email?
|
||||
return false unless incoming_email? || inbox.email?
|
||||
|
||||
content_attributes.dig(:email, :auto_reply) == true
|
||||
end
|
||||
|
||||
def valid_first_reply?
|
||||
return false unless human_response? && !private?
|
||||
return false if conversation.first_reply_created_at.present?
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -103,8 +103,7 @@ class MailPresenter < SimpleDelegator
|
||||
references: references,
|
||||
subject: subject,
|
||||
text_content: text_content,
|
||||
to: to,
|
||||
auto_reply: auto_reply?
|
||||
to: to
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::AssignmentService
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def perform_for_conversation(conversation)
|
||||
return false unless can_assign?(conversation)
|
||||
|
||||
agent = find_agent_for_conversation(conversation)
|
||||
return false unless agent
|
||||
|
||||
assign_conversation_to_agent(conversation, agent)
|
||||
end
|
||||
|
||||
def perform_bulk_assignment(limit: 50)
|
||||
return 0 unless assignment_enabled?
|
||||
|
||||
conversations = unassigned_conversations(limit)
|
||||
assigned_count = 0
|
||||
|
||||
conversations.find_each do |conversation|
|
||||
assigned_count += 1 if perform_for_conversation(conversation)
|
||||
end
|
||||
|
||||
assigned_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def assignment_enabled?
|
||||
policy&.enabled?
|
||||
end
|
||||
|
||||
def can_assign?(conversation)
|
||||
assignment_enabled? &&
|
||||
conversation.status == 'open' &&
|
||||
conversation.assignee_id.nil?
|
||||
end
|
||||
|
||||
def find_agent_for_conversation(_conversation)
|
||||
available_agents = inbox.available_agents(check_rate_limits: true)
|
||||
|
||||
if available_agents.empty?
|
||||
log_no_agents_available
|
||||
return nil
|
||||
end
|
||||
|
||||
selector_service.select_agent(available_agents)
|
||||
end
|
||||
|
||||
def selector_service
|
||||
@selector_service ||= if policy.assignment_order == 'balanced' && enterprise_enabled? && policy.can_use_balanced_assignment?
|
||||
::Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
|
||||
else
|
||||
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations
|
||||
.unassigned
|
||||
.open
|
||||
|
||||
# Apply conversation priority ordering
|
||||
scope = case policy.conversation_priority
|
||||
when 'longest_waiting'
|
||||
scope.order(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.order(created_at: :asc)
|
||||
end
|
||||
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def assign_conversation_to_agent(conversation, agent)
|
||||
conversation.update!(assignee: agent)
|
||||
create_assignment_activity(conversation, agent)
|
||||
record_assignment_in_rate_limiter(conversation, agent)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def create_assignment_activity(conversation, agent)
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
Events::Types::ASSIGNEE_CHANGED,
|
||||
Time.zone.now,
|
||||
conversation: conversation,
|
||||
user: agent
|
||||
)
|
||||
end
|
||||
|
||||
def enterprise_enabled?
|
||||
@enterprise_enabled ||= defined?(Enterprise)
|
||||
end
|
||||
|
||||
def log_no_agents_available
|
||||
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
|
||||
end
|
||||
|
||||
def record_assignment_in_rate_limiter(conversation, agent)
|
||||
rate_limiter = AssignmentV2::RateLimiter.new(inbox: inbox, user: agent)
|
||||
rate_limiter.record_assignment(conversation)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -1,85 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Rate limiter for assignment operations
|
||||
# Uses Redis to track assignment counts per agent per time window
|
||||
# based on assignment policy's fair_distribution_limit and fair_distribution_window
|
||||
class AssignmentV2::RateLimiter
|
||||
pattr_initialize [:inbox!, :user!]
|
||||
|
||||
# Check if the user has exceeded rate limits
|
||||
# @return [Boolean] true if within limits, false if exceeded
|
||||
def within_limits?
|
||||
return true unless policy_exists?
|
||||
|
||||
current_count < rate_limit
|
||||
end
|
||||
|
||||
# Record an assignment for rate limiting purposes
|
||||
# @param conversation [Conversation] The conversation being assigned
|
||||
def record_assignment(_conversation)
|
||||
return unless policy_exists?
|
||||
|
||||
key = rate_limit_key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.multi do |multi|
|
||||
multi.incr(key)
|
||||
multi.expire(key, time_window)
|
||||
end
|
||||
end
|
||||
|
||||
# Get current rate limit status for the user
|
||||
# @return [Hash] Rate limit status information
|
||||
def status
|
||||
if policy_exists?
|
||||
{
|
||||
within_limits: within_limits?,
|
||||
current_count: current_count,
|
||||
limit: rate_limit,
|
||||
reset_at: Time.zone.at(next_window_start)
|
||||
}
|
||||
else
|
||||
{
|
||||
within_limits: true,
|
||||
current_count: 0,
|
||||
limit: Float::INFINITY,
|
||||
reset_at: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def policy_exists?
|
||||
policy.present? && policy.enabled?
|
||||
end
|
||||
|
||||
def current_count
|
||||
key = rate_limit_key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.get(key).to_i
|
||||
end
|
||||
|
||||
def rate_limit
|
||||
policy&.fair_distribution_limit || 10
|
||||
end
|
||||
|
||||
def time_window
|
||||
policy&.fair_distribution_window || 3600
|
||||
end
|
||||
|
||||
def rate_limit_key
|
||||
"assignment_v2:rate_limit:#{user.id}:#{current_window}"
|
||||
end
|
||||
|
||||
def current_window
|
||||
(Time.current.to_i / time_window) * time_window
|
||||
end
|
||||
|
||||
def next_window_start
|
||||
current_window + time_window
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AssignmentV2::RoundRobinSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
# Extract user IDs from inbox members
|
||||
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
|
||||
|
||||
# Use Redis queue for round robin
|
||||
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
|
||||
return nil unless selected_user_id
|
||||
|
||||
# Return the user object
|
||||
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
|
||||
end
|
||||
|
||||
def add_agent_to_queue(user_id)
|
||||
round_robin_service.add_agent_to_queue(user_id)
|
||||
end
|
||||
|
||||
def remove_agent_from_queue(user_id)
|
||||
round_robin_service.remove_agent_from_queue(user_id)
|
||||
end
|
||||
|
||||
def reset_queue
|
||||
round_robin_service.reset_queue
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def round_robin_service
|
||||
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,6 @@ class MessageTemplates::HookExecutionService
|
||||
def perform
|
||||
return if conversation.campaign.present?
|
||||
return if conversation.last_incoming_message.blank?
|
||||
return if message.auto_reply_email?
|
||||
|
||||
trigger_templates
|
||||
end
|
||||
|
||||
@@ -88,16 +88,12 @@ class Twilio::IncomingMessageService
|
||||
|
||||
def contact_attributes
|
||||
{
|
||||
name: contact_name,
|
||||
name: formatted_phone_number,
|
||||
phone_number: phone_number,
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def contact_name
|
||||
params[:ProfileName].presence || formatted_phone_number
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
if twilio_channel.sms?
|
||||
{
|
||||
|
||||
@@ -24,3 +24,18 @@
|
||||
title: 'Add Label to Conversation'
|
||||
description: 'Add a label to a conversation'
|
||||
icon: 'tag'
|
||||
|
||||
- id: faq_lookup
|
||||
title: 'FAQ Lookup'
|
||||
description: 'Search FAQ responses using semantic similarity'
|
||||
icon: 'search'
|
||||
|
||||
- id: handoff
|
||||
title: 'Handoff to Human'
|
||||
description: 'Hand off the conversation to a human agent'
|
||||
icon: 'user-switch'
|
||||
|
||||
- id: better_stack_status
|
||||
title: 'Better Stack Status'
|
||||
description: 'Check the status of Better Stack services'
|
||||
icon: 'server'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'agents'
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4.1-mini'
|
||||
|
||||
if api_key.present?
|
||||
Agents.configure do |config|
|
||||
config.openai_api_key = api_key
|
||||
config.default_model = model
|
||||
config.debug = false
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to configure AI Agents SDK: #{e.message}"
|
||||
end
|
||||
@@ -26,9 +26,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
@response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
end
|
||||
|
||||
return process_action('handoff') if handoff_requested?
|
||||
|
||||
@@ -104,4 +110,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def log_error(error)
|
||||
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
return account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
class Captain::Assistant < ApplicationRecord
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
self.table_name = 'captain_assistants'
|
||||
|
||||
@@ -35,6 +36,8 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
validates :account_id, presence: true
|
||||
@@ -71,6 +74,33 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
name
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
]
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
name: name,
|
||||
description: description,
|
||||
product_name: config['product_name'] || 'this product',
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
key: scenario.title.parameterize.underscore,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
response_guidelines: response_guidelines || [],
|
||||
guardrails: guardrails || []
|
||||
}
|
||||
end
|
||||
|
||||
def default_avatar_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
||||
end
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
@@ -37,10 +38,43 @@ class Captain::Scenario < ApplicationRecord
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
instructions: resolved_instructions,
|
||||
tools: resolved_tools
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".titleize
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
|
||||
end
|
||||
|
||||
def resolved_instructions
|
||||
instruction.gsub(TOOL_REFERENCE_REGEX) do |match|
|
||||
"#{match} tool "
|
||||
end
|
||||
end
|
||||
|
||||
def resolved_tools
|
||||
return [] if tools.blank?
|
||||
|
||||
available_tools = self.class.available_agent_tools
|
||||
tools.filter_map do |tool_id|
|
||||
available_tools.find { |tool| tool[:id] == tool_id }
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that all tool references in the instruction are valid.
|
||||
# Parses the instruction for tool references and checks if they exist
|
||||
# in the available tools configuration.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
module Concerns::Agentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def agent
|
||||
Agents::Agent.new(
|
||||
name: agent_name,
|
||||
instructions: ->(context) { agent_instructions(context) },
|
||||
tools: agent_tools,
|
||||
model: agent_model,
|
||||
temperature: temperature || 0.7,
|
||||
response_schema: agent_response_schema
|
||||
)
|
||||
end
|
||||
|
||||
def agent_instructions(context = nil)
|
||||
enhanced_context = prompt_context
|
||||
|
||||
if context
|
||||
state = context.context[:state] || {}
|
||||
conversation_data = state[:conversation] || {}
|
||||
contact_data = state[:contact] || {}
|
||||
enhanced_context = enhanced_context.merge(
|
||||
conversation: conversation_data,
|
||||
contact: contact_data
|
||||
)
|
||||
end
|
||||
|
||||
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
raise NotImplementedError, "#{self.class} must implement agent_name"
|
||||
end
|
||||
|
||||
def template_name
|
||||
self.class.name.demodulize.underscore
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[] # Default implementation, override if needed
|
||||
end
|
||||
|
||||
def agent_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4.1-mini'
|
||||
end
|
||||
|
||||
def agent_response_schema
|
||||
Captain::ResponseSchema
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
raise NotImplementedError, "#{self.class} must implement prompt_context"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Assistant::AgentRunnerService
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
agents = build_and_wire_agents
|
||||
context = build_context(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
runner = Agents::Runner.with_agents(*agents)
|
||||
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
||||
result = runner.run(message_to_process, context: context)
|
||||
|
||||
process_agent_result(result)
|
||||
rescue StandardError => e
|
||||
# when running the agent runner service in a rake task, the conversation might not have an account associated
|
||||
# for regular production usage, it will run just fine
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
error_response(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_context(message_history)
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = extract_text_from_content(msg[:content])
|
||||
|
||||
{
|
||||
role: msg[:role].to_sym,
|
||||
content: content,
|
||||
agent_name: msg[:agent_name]
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
conversation_history: conversation_history,
|
||||
state: build_state
|
||||
}
|
||||
end
|
||||
|
||||
def extract_last_user_message(message_history)
|
||||
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
|
||||
|
||||
extract_text_from_content(last_user_msg[:content])
|
||||
end
|
||||
|
||||
def extract_text_from_content(content)
|
||||
# Handle structured output from agents
|
||||
return content[:response] || content['response'] || content.to_s if content.is_a?(Hash)
|
||||
|
||||
return content unless content.is_a?(Array)
|
||||
|
||||
text_parts = content.select { |part| part[:type] == 'text' }.pluck(:text)
|
||||
text_parts.join(' ')
|
||||
end
|
||||
|
||||
# Response formatting methods
|
||||
def process_agent_result(result)
|
||||
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
|
||||
format_response(result.output)
|
||||
end
|
||||
|
||||
def format_response(output)
|
||||
return output.with_indifferent_access if output.is_a?(Hash)
|
||||
|
||||
# Fallback for backwards compatibility
|
||||
{
|
||||
'response' => output.to_s,
|
||||
'reasoning' => 'Processed by agent'
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(error_message)
|
||||
{
|
||||
'response' => 'conversation_handoff',
|
||||
'reasoning' => "Error occurred: #{error_message}"
|
||||
}
|
||||
end
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
|
||||
if @conversation
|
||||
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
|
||||
scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) }
|
||||
|
||||
[assistant_agent] + scenario_agents
|
||||
end
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
if @callbacks[:on_agent_thinking]
|
||||
runner = runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if @callbacks[:on_tool_start]
|
||||
runner = runner.on_tool_start do |*args|
|
||||
@callbacks[:on_tool_start].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if @callbacks[:on_tool_complete]
|
||||
runner = runner.on_tool_complete do |*args|
|
||||
@callbacks[:on_tool_complete].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if @callbacks[:on_agent_handoff]
|
||||
runner = runner.on_agent_handoff do |*args|
|
||||
@callbacks[:on_agent_handoff].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
runner
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
require 'liquid'
|
||||
|
||||
class Captain::PromptRenderer
|
||||
class << self
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_template(template_name)
|
||||
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
|
||||
raise "Template not found: #{template_name}" unless File.exist?(template_path)
|
||||
|
||||
File.read(template_path)
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
# System Context
|
||||
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
|
||||
|
||||
# Your Identity
|
||||
You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need.
|
||||
|
||||
{{ description }}
|
||||
|
||||
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this.
|
||||
|
||||
# Current Context
|
||||
|
||||
Here's the metadata we have about the current conversation and the contact associated with it:
|
||||
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
{% endif -%}
|
||||
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% endif -%}
|
||||
|
||||
{% if response_guidelines.size > 0 -%}
|
||||
# Response Guidelines
|
||||
Your responses should follow these guidelines:
|
||||
{% for guideline in response_guidelines -%}
|
||||
- {{ guideline }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
{% if guardrails.size > 0 -%}
|
||||
# Guardrails
|
||||
Always respect these boundaries:
|
||||
{% for guardrail in guardrails -%}
|
||||
- {{ guardrail }}
|
||||
{% endfor %}
|
||||
{% endif -%}
|
||||
|
||||
# Decision Framework
|
||||
|
||||
## 1. Analyze the Request
|
||||
First, understand what the user is asking:
|
||||
- **Intent**: What are they trying to achieve?
|
||||
- **Type**: Is it a question, task, complaint, or request?
|
||||
- **Complexity**: Can you handle it or does it need specialized expertise?
|
||||
|
||||
## 2. Check for Specialized Scenarios
|
||||
If the request matches any of these scenarios, hand off to the appropriate specialist:
|
||||
|
||||
{% for scenario in scenarios -%}
|
||||
### handoff_to_{{ scenario.key }}
|
||||
{{ scenario.description }}
|
||||
{% endfor -%}
|
||||
|
||||
## 3. Handle the Request
|
||||
If no specialized scenario matches, handle it yourself:
|
||||
|
||||
### For Questions and Information Requests
|
||||
1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information
|
||||
2. **If not found in FAQs**: Provide your best answer based on available context
|
||||
3. **If unable to answer**: Use `handoff` tool to transfer to a human expert
|
||||
|
||||
### For Complex or Unclear Requests
|
||||
1. **Ask clarifying questions**: Gather more information if needed
|
||||
2. **Break down complex tasks**: Handle step by step or hand off if too complex
|
||||
3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities
|
||||
|
||||
## Response Best Practices
|
||||
- Be conversational but professional
|
||||
- Provide actionable information
|
||||
- Include relevant details from tool responses
|
||||
|
||||
# Human Handoff Protocol
|
||||
Transfer to a human agent when:
|
||||
- User explicitly requests human assistance
|
||||
- You cannot find needed information after checking FAQs
|
||||
- The issue requires specialized knowledge or permissions you don't have
|
||||
- Multiple attempts to help have been unsuccessful
|
||||
|
||||
When using the `handoff` tool, provide a clear reason that helps the human agent understand the context.
|
||||
@@ -0,0 +1,24 @@
|
||||
# System context
|
||||
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task.
|
||||
The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
|
||||
|
||||
# Your Role
|
||||
You are a specialized agent called {{ title }}, your task is to handle the following scenario:
|
||||
|
||||
{{ instructions }}
|
||||
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
|
||||
{% if tools.size > 0 -%}
|
||||
# Available Tools
|
||||
You have access to these tools:
|
||||
{% for tool in tools -%}
|
||||
- {{ tool.id }}: {{ tool.description }}
|
||||
{% endfor %}
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Contact Information
|
||||
- Contact ID: {{ contact.id }}
|
||||
- Name: {{ contact.name || "Unknown" }}
|
||||
- Email: {{ contact.email || "None" }}
|
||||
- Phone: {{ contact.phone_number || "None" }}
|
||||
- Identifier: {{ contact.identifier || "None" }}
|
||||
- Type: {{ contact.contact_type || "visitor" }}
|
||||
{% if contact.custom_attributes -%}
|
||||
{% for attribute in contact.custom_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
{% if contact.additional_attributes -%}
|
||||
{% for attribute in contact.additional_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Current Conversation Context
|
||||
- Conversation ID: {{ conversation.display_id }}
|
||||
- Contact ID: {{ conversation.contact_id }}
|
||||
- Status: {{ conversation.status }}
|
||||
- Priority: {{ conversation.priority || "None" }}
|
||||
{% if conversation.label_list.size > 0 -%}
|
||||
- Labels: {{ conversation.label_list | join: ", " }}
|
||||
{% endif -%}
|
||||
{% if conversation.custom_attributes -%}
|
||||
{% for attribute in conversation.custom_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
{% if conversation.additional_attributes -%}
|
||||
{% for attribute in conversation.additional_attributes -%}
|
||||
- {{ attribute[0] }}: {{ attribute[1] }}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
@@ -0,0 +1,4 @@
|
||||
class Captain::ResponseSchema < RubyLLM::Schema
|
||||
string :response, description: 'The message to send to the user'
|
||||
string :reasoning, description: "Agent's thought process"
|
||||
end
|
||||
@@ -0,0 +1,138 @@
|
||||
class Captain::Tools::BetterStackStatusTool < Captain::Tools::BasePublicTool
|
||||
description 'Check service status using Better Stack'
|
||||
param :service, type: 'string', desc: 'The service name to check status for (optional)'
|
||||
|
||||
def perform(_tool_context, service: nil)
|
||||
log_tool_usage('checking_status', { service: service })
|
||||
|
||||
begin
|
||||
status_data = fetch_better_stack_status(service)
|
||||
incidents_data = fetch_better_stack_incidents(service)
|
||||
format_status_response(status_data, incidents_data, service)
|
||||
rescue StandardError => e
|
||||
log_tool_usage('error', { service: service, error: e.message })
|
||||
"Error checking Better Stack status: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_better_stack_status(service)
|
||||
# API token should be configured in environment variables or secrets
|
||||
api_token = ENV.fetch('BETTER_STACK_TOKEN')
|
||||
raise 'Better Stack API token not configured' if api_token.blank?
|
||||
|
||||
# Base URL for Better Stack Status API
|
||||
base_url = 'https://uptime.betterstack.com/api/v2'
|
||||
|
||||
# Endpoint depends on whether we're checking a specific service or overall status
|
||||
endpoint = if service.present?
|
||||
"/monitors?search=#{CGI.escape(service)}"
|
||||
else
|
||||
'/monitors'
|
||||
end
|
||||
|
||||
make_api_request(base_url, endpoint, api_token)
|
||||
end
|
||||
|
||||
def fetch_better_stack_incidents(service)
|
||||
api_token = ENV.fetch('BETTER_STACK_TOKEN')
|
||||
raise 'Better Stack API token not configured' if api_token.blank?
|
||||
|
||||
base_url = 'https://uptime.betterstack.com/api/v2'
|
||||
|
||||
# Endpoint for incidents, filter by service if provided
|
||||
endpoint = if service.present?
|
||||
"/incidents?monitor_name=#{CGI.escape(service)}"
|
||||
else
|
||||
'/incidents'
|
||||
end
|
||||
|
||||
make_api_request(base_url, endpoint, api_token)
|
||||
end
|
||||
|
||||
def make_api_request(base_url, endpoint, api_token)
|
||||
uri = URI("#{base_url}#{endpoint}")
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
request['Authorization'] = "Bearer #{api_token}"
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
raise "API request failed with status #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def format_status_response(status_data, incidents_data, service)
|
||||
monitors = status_data['data']
|
||||
incidents = incidents_data['data']
|
||||
|
||||
if monitors.empty?
|
||||
return service.present? ? "No services found matching '#{service}'" : 'No services configured in Better Stack'
|
||||
end
|
||||
|
||||
result = if service.present?
|
||||
"Status for services matching '#{service}':\n\n"
|
||||
else
|
||||
"Current Better Stack Status:\n\n"
|
||||
end
|
||||
|
||||
result += format_monitors(monitors)
|
||||
|
||||
# Add incidents information if any exist
|
||||
if incidents.present?
|
||||
result += "\n\n🚨 Active Incidents (#{incidents.size}):\n\n"
|
||||
result += format_incidents(incidents)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def format_monitors(monitors)
|
||||
monitors.map do |monitor|
|
||||
attributes = monitor['attributes']
|
||||
status = attributes['status']
|
||||
status_emoji = case status
|
||||
when 'up'
|
||||
'✅'
|
||||
when 'down'
|
||||
'❌'
|
||||
else
|
||||
'⚠️'
|
||||
end
|
||||
|
||||
last_check = begin
|
||||
Time.zone.parse(attributes['last_check_at'])
|
||||
rescue StandardError
|
||||
'Unknown'
|
||||
end
|
||||
last_check_formatted = last_check.is_a?(Time) ? last_check.strftime('%Y-%m-%d %H:%M:%S') : last_check
|
||||
|
||||
"#{status_emoji} #{attributes['url']}: #{status.upcase} (Last checked: #{last_check_formatted})"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def format_incidents(incidents)
|
||||
incidents.map do |incident|
|
||||
attributes = incident['attributes']
|
||||
# Format start time
|
||||
started_at = begin
|
||||
Time.zone.parse(attributes['started_at'])
|
||||
rescue StandardError
|
||||
'Unknown'
|
||||
end
|
||||
started_at_formatted = started_at.is_a?(Time) ? started_at.strftime('%Y-%m-%d %H:%M:%S') : started_at
|
||||
|
||||
# Get monitor name and incident status
|
||||
status = attributes['status'] || 'unknown'
|
||||
|
||||
# Format incident details
|
||||
"🔴 [#{status.upcase}] #{attributes['name']} - #{attributes['title']}\n " \
|
||||
"Started: #{started_at_formatted}\n " \
|
||||
"#{attributes['url'] ? "Details: #{attributes['url']}" : ''}"
|
||||
end.join("\n\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
# Use existing vector search on approved responses
|
||||
responses = @assistant.responses.approved.search(query).to_a
|
||||
|
||||
if responses.empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
"No relevant FAQs found for: #{query}"
|
||||
else
|
||||
log_tool_usage('found_results', { query: query, count: responses.size })
|
||||
format_responses(responses)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_responses(responses)
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
|
||||
formatted_response
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
description 'Hand off the conversation to a human agent when unable to assist further'
|
||||
param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false
|
||||
|
||||
def perform(tool_context, reason: nil)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
# Log the handoff with reason
|
||||
log_tool_usage('manual_handoff', {
|
||||
conversation_id: conversation.id,
|
||||
reason: reason || 'Agent requested handoff'
|
||||
})
|
||||
|
||||
# Use existing handoff mechanism from ResponseBuilderJob
|
||||
trigger_handoff(conversation, reason)
|
||||
|
||||
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
'Failed to handoff conversation'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trigger_handoff(conversation, reason)
|
||||
# post the reason as a private note
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
# This tool could be enhanced to:
|
||||
# 1. Accept team_id parameter for routing to specific teams
|
||||
# 2. Set conversation priority based on handoff reason
|
||||
# 3. Add metadata for intelligent agent assignment
|
||||
# 4. Support escalation levels (L1 -> L2 -> L3)
|
||||
#
|
||||
# Example future signature:
|
||||
# param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false
|
||||
# param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false
|
||||
# param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false
|
||||
end
|
||||
@@ -23,7 +23,9 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool
|
||||
end
|
||||
|
||||
def normalize_priority(priority)
|
||||
priority == 'nil' || priority.blank? ? nil : priority
|
||||
return nil if priority == 'nil' || priority.blank?
|
||||
|
||||
priority.downcase
|
||||
end
|
||||
|
||||
def valid_priority?(priority)
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
###############
|
||||
# One library to capture_exception and send to the specific service.
|
||||
# # e as exception, u for user and a for account (user and account are optional)
|
||||
# Usage: ChatwootExceptionTracker(e, user: u, account: a).capture_exception
|
||||
############
|
||||
|
||||
class ChatwootExceptionTracker
|
||||
def initialize(exception, user: nil, account: nil)
|
||||
@exception = exception
|
||||
@@ -11,13 +5,30 @@ class ChatwootExceptionTracker
|
||||
@account = account
|
||||
end
|
||||
|
||||
def capture_exception
|
||||
def capture_exception(additional_info = {})
|
||||
write_exception_to_file(additional_info) if Rails.env.development?
|
||||
capture_exception_with_sentry if ENV['SENTRY_DSN'].present?
|
||||
Rails.logger.error @exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_exception_to_file(additional_info = {})
|
||||
error_dir = Rails.root.join('errors')
|
||||
FileUtils.mkdir_p(error_dir)
|
||||
timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
|
||||
filename = "#{timestamp}-#{SecureRandom.hex(4)}.log"
|
||||
filepath = error_dir.join(filename)
|
||||
|
||||
File.open(filepath, 'w') do |file|
|
||||
file.puts "Exception: #{@exception.class} - #{@exception.message}"
|
||||
file.puts "User: #{@user&.id} (#{@user&.email})" if @user
|
||||
file.puts "Account: #{@account&.id} (#{@account&.name})" if @account
|
||||
file.puts "Backtrace:\n#{@exception.backtrace&.join("\n")}"
|
||||
file.puts "Additional Info: #{JSON.pretty_generate(additional_info)}" if additional_info.present?
|
||||
end
|
||||
end
|
||||
|
||||
def capture_exception_with_sentry
|
||||
Sentry.with_scope do |scope|
|
||||
if @account.present?
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :assignment_v2 do
|
||||
desc 'Enable Assignment V2 for an account with default policy'
|
||||
task :enable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Create default assignment policy if not exists
|
||||
policy = account.assignment_policies.find_or_create_by!(name: 'Default Policy') do |p|
|
||||
p.description = 'Default round-robin assignment policy'
|
||||
p.assignment_order = 'round_robin'
|
||||
p.conversation_priority = 'earliest_created'
|
||||
p.enabled = true
|
||||
end
|
||||
|
||||
puts "Assignment V2 enabled for account #{account.name} with policy #{policy.name}"
|
||||
end
|
||||
|
||||
desc 'Disable Assignment V2 for an account'
|
||||
task :disable, [:account_id] => :environment do |_task, args|
|
||||
account = Account.find(args[:account_id])
|
||||
|
||||
# Disable all assignment policies
|
||||
account.assignment_policies.find_each do |policy|
|
||||
policy.update!(enabled: false)
|
||||
end
|
||||
|
||||
puts "Assignment V2 disabled for account #{account.name}"
|
||||
end
|
||||
|
||||
desc 'Run assignment for all enabled inboxes'
|
||||
task run_all: :environment do
|
||||
count = 0
|
||||
|
||||
Inbox.joins(:assignment_policy)
|
||||
.where(assignment_policies: { enabled: true })
|
||||
.find_each do |inbox|
|
||||
AssignmentV2::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
count += 1
|
||||
end
|
||||
|
||||
puts "Queued assignment jobs for #{count} inboxes"
|
||||
end
|
||||
|
||||
desc 'Show Assignment V2 status'
|
||||
task status: :environment do
|
||||
puts 'Assignment V2 Status'
|
||||
puts '==================='
|
||||
|
||||
total_policies = AssignmentPolicy.count
|
||||
enabled_policies = AssignmentPolicy.enabled.count
|
||||
|
||||
puts "Total Assignment Policies: #{total_policies}"
|
||||
puts "Enabled Policies: #{enabled_policies}"
|
||||
puts
|
||||
|
||||
inboxes_with_v2 = Inbox.joins(:assignment_policy).count
|
||||
total_inboxes = Inbox.count
|
||||
|
||||
puts "Total Inboxes: #{total_inboxes}"
|
||||
puts "Inboxes with Assignment V2: #{inboxes_with_v2}"
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
@@ -0,0 +1,198 @@
|
||||
require 'io/console'
|
||||
require 'readline'
|
||||
|
||||
namespace :captain do
|
||||
desc 'Start interactive chat with Captain assistant - Usage: rake captain:chat[assistant_id] or rake captain:chat -- assistant_id'
|
||||
task :chat, [:assistant_id] => :environment do |_, args|
|
||||
assistant_id = args[:assistant_id] || ARGV[1]
|
||||
|
||||
unless assistant_id
|
||||
puts '❌ Please provide an assistant ID'
|
||||
puts 'Usage: rake captain:chat[assistant_id]'
|
||||
puts "\nAvailable assistants:"
|
||||
Captain::Assistant.includes(:account).each do |assistant|
|
||||
puts " ID: #{assistant.id} - #{assistant.name} (Account: #{assistant.account.name})"
|
||||
end
|
||||
exit 1
|
||||
end
|
||||
|
||||
assistant = Captain::Assistant.find_by(id: assistant_id)
|
||||
unless assistant
|
||||
puts "❌ Assistant with ID #{assistant_id} not found"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Clear ARGV to prevent gets from reading files
|
||||
ARGV.clear
|
||||
|
||||
chat_session = CaptainChatSession.new(assistant)
|
||||
chat_session.start
|
||||
end
|
||||
end
|
||||
|
||||
class CaptainChatSession
|
||||
def initialize(assistant)
|
||||
@assistant = assistant
|
||||
@message_history = []
|
||||
end
|
||||
|
||||
def start
|
||||
show_assistant_info
|
||||
puts "💡 Type 'exit', 'quit', or 'bye' to end the session"
|
||||
puts "💡 Type 'clear' to clear message history"
|
||||
puts "#{'-' * 50}"
|
||||
|
||||
loop do
|
||||
puts '' # Add spacing before prompt
|
||||
user_input = Readline.readline('👤 You: ', true)
|
||||
next unless user_input # Handle Ctrl+D
|
||||
|
||||
user_input = user_input.strip
|
||||
case user_input.downcase
|
||||
when 'exit', 'quit', 'bye'
|
||||
break
|
||||
when 'clear'
|
||||
clear_history
|
||||
next
|
||||
when ''
|
||||
next
|
||||
end
|
||||
|
||||
process_user_message(user_input)
|
||||
end
|
||||
|
||||
puts "\nChat session ended"
|
||||
puts "Final conversation log has #{@message_history.length} messages"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def show_assistant_info
|
||||
puts "🤖 Starting chat with #{@assistant.name}"
|
||||
puts "🏢 Account: #{@assistant.account.name}"
|
||||
puts "🆔 Assistant ID: #{@assistant.id}"
|
||||
|
||||
# Show scenarios
|
||||
scenarios = @assistant.scenarios.enabled
|
||||
if scenarios.any?
|
||||
puts "⚡ Enabled Scenarios (#{scenarios.count}):"
|
||||
scenarios.each do |scenario|
|
||||
tools_count = scenario.tools&.length || 0
|
||||
puts " • #{scenario.title} (#{tools_count} tools)"
|
||||
if scenario.description.present?
|
||||
description = scenario.description.length > 60 ? "#{scenario.description[0..60]}..." : scenario.description
|
||||
puts " #{description}"
|
||||
end
|
||||
end
|
||||
else
|
||||
puts '⚡ No scenarios enabled'
|
||||
end
|
||||
|
||||
# Show available tools
|
||||
available_tools = Captain::Assistant.available_tool_ids
|
||||
if available_tools.any?
|
||||
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
|
||||
else
|
||||
puts '🔧 No tools available'
|
||||
end
|
||||
|
||||
puts ''
|
||||
end
|
||||
|
||||
def process_user_message(user_input)
|
||||
# Add user message to history
|
||||
add_to_history('user', user_input)
|
||||
|
||||
begin
|
||||
print "🤖 #{@assistant.name}: "
|
||||
|
||||
# Track system messages to group them
|
||||
@current_system_messages = []
|
||||
|
||||
# Define callbacks for detailed visibility
|
||||
callbacks = {
|
||||
on_agent_thinking: proc { |agent, input|
|
||||
agent_name = extract_name(agent)
|
||||
@current_system_messages << "#{agent_name} is thinking..."
|
||||
add_to_history('system', "#{agent_name} is thinking...")
|
||||
},
|
||||
on_tool_start: proc { |tool, args|
|
||||
tool_name = extract_tool_name(tool)
|
||||
@current_system_messages << "Using tool: #{tool_name}"
|
||||
add_to_history('system', "Using tool: #{tool_name}")
|
||||
},
|
||||
on_tool_complete: proc { |tool, result|
|
||||
tool_name = extract_tool_name(tool)
|
||||
@current_system_messages << "Tool #{tool_name} completed"
|
||||
add_to_history('system', "Tool #{tool_name} completed")
|
||||
},
|
||||
on_agent_handoff: proc { |from, to, reason|
|
||||
@current_system_messages << "Handoff: #{extract_name(from)} → #{extract_name(to)} (#{reason})"
|
||||
add_to_history('system', "Agent handoff: #{extract_name(from)} → #{extract_name(to)} (#{reason})")
|
||||
}
|
||||
}
|
||||
|
||||
# Generate response using AgentRunnerService with callbacks
|
||||
runner = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, callbacks: callbacks)
|
||||
result = runner.generate_response(message_history: @message_history)
|
||||
|
||||
response_text = result['response'] || 'No response generated'
|
||||
reasoning = result['reasoning']
|
||||
|
||||
# Print grouped system messages with dimmer styling
|
||||
puts dim_text("\n" + @current_system_messages.join("\n")) if @current_system_messages.any?
|
||||
|
||||
# Print the response
|
||||
puts response_text
|
||||
|
||||
puts dim_italic_text("(Reasoning: #{reasoning})") if reasoning && reasoning != 'Processed by agent'
|
||||
|
||||
# Add assistant response to history
|
||||
add_to_history('assistant', response_text, reasoning: reasoning)
|
||||
|
||||
rescue StandardError => e
|
||||
error_msg = "Error: #{e.message}"
|
||||
puts "❌ #{error_msg}"
|
||||
add_to_history('system', error_msg)
|
||||
end
|
||||
end
|
||||
|
||||
def add_to_history(role, content, agent_name: nil, reasoning: nil)
|
||||
message = {
|
||||
role: role,
|
||||
content: content,
|
||||
timestamp: Time.current,
|
||||
agent_name: agent_name || (role == 'assistant' ? @assistant.name : nil)
|
||||
}
|
||||
message[:reasoning] = reasoning if reasoning
|
||||
|
||||
@message_history << message
|
||||
end
|
||||
|
||||
def clear_history
|
||||
@message_history.clear
|
||||
puts 'Message history cleared'
|
||||
end
|
||||
|
||||
def dim_text(text)
|
||||
# ANSI escape code for very dim gray text (bright black/dark gray)
|
||||
"\e[90m#{text}\e[0m"
|
||||
end
|
||||
|
||||
def dim_italic_text(text)
|
||||
# ANSI escape codes for dim gray + italic text
|
||||
"\e[90m\e[3m#{text}\e[0m"
|
||||
end
|
||||
|
||||
def extract_tool_name(tool)
|
||||
return tool if tool.is_a?(String)
|
||||
|
||||
tool.class.name.split('::').last.gsub('Tool', '')
|
||||
rescue StandardError
|
||||
tool.to_s
|
||||
end
|
||||
|
||||
def extract_name(obj)
|
||||
obj.respond_to?(:name) ? obj.name : obj.to_s
|
||||
end
|
||||
end
|
||||
@@ -1,34 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :assignment_policy do
|
||||
account
|
||||
sequence(:name) { |n| "Assignment Policy #{n}" }
|
||||
description { 'Test assignment policy' }
|
||||
assignment_order { :round_robin }
|
||||
conversation_priority { :earliest_created }
|
||||
fair_distribution_limit { 10 }
|
||||
fair_distribution_window { 3600 }
|
||||
enabled { true }
|
||||
|
||||
trait :balanced do
|
||||
assignment_order { :balanced }
|
||||
end
|
||||
|
||||
trait :disabled do
|
||||
enabled { false }
|
||||
end
|
||||
|
||||
trait :longest_waiting do
|
||||
conversation_priority { :longest_waiting }
|
||||
end
|
||||
|
||||
trait :with_high_limit do
|
||||
fair_distribution_limit { 50 }
|
||||
end
|
||||
|
||||
trait :with_short_window do
|
||||
fair_distribution_window { 300 } # 5 minutes
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :inbox_assignment_policy do
|
||||
inbox
|
||||
assignment_policy
|
||||
|
||||
# Ensure inbox and policy belong to same account
|
||||
after(:build) do |inbox_policy|
|
||||
inbox_policy.assignment_policy.account = inbox_policy.inbox.account if inbox_policy.inbox && inbox_policy.assignment_policy
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,194 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::AssignmentJob, type: :job do
|
||||
before do
|
||||
# Mock GlobalConfig to avoid InstallationConfig issues
|
||||
allow(GlobalConfig).to receive(:get).and_return({})
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with conversation_id' do
|
||||
it 'assigns a single conversation' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
expect(service).to receive(:perform_for_conversation).with(conversation)
|
||||
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
end
|
||||
|
||||
it 'handles non-existent conversation gracefully' do
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
# Should not raise error
|
||||
expect do
|
||||
described_class.new.perform(conversation_id: 999_999)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with inbox_id' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
before do
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: nil)
|
||||
create(:inbox_member, inbox: inbox, user: agent)
|
||||
end
|
||||
|
||||
it 'assigns multiple conversations for inbox' do
|
||||
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
|
||||
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
expect(service).to receive(:perform_bulk_assignment).and_return(3)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'logs the number of assigned conversations' do
|
||||
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
|
||||
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_return(2)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("AssignmentV2::AssignmentJob: Assigned 2 conversations for inbox #{inbox.id}")
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'skips assignment when inbox has no policy' do
|
||||
inbox_assignment_policy.destroy!
|
||||
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'skips assignment when policy is disabled' do
|
||||
assignment_policy.update!(enabled: false)
|
||||
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
it 'handles non-existent inbox gracefully' do
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
# Should not raise error
|
||||
expect do
|
||||
described_class.new.perform(inbox_id: 999_999)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'without parameters' do
|
||||
it 'logs error when no parameters provided' do
|
||||
expect(Rails.logger).to receive(:error).with('AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided')
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
it 'does not attempt assignment' do
|
||||
expect(AssignmentV2::AssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with both parameters' do
|
||||
it 'prioritizes conversation_id over inbox_id' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
|
||||
expect(service).to receive(:perform_for_conversation).with(conversation)
|
||||
expect(service).not_to receive(:perform_bulk_assignment)
|
||||
|
||||
described_class.new.perform(conversation_id: conversation.id, inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'uses the low queue' do
|
||||
expect(described_class.new.queue_name).to eq('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error handling' do
|
||||
context 'when assignment service raises error' do
|
||||
it 'propagates the error for retry' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_for_conversation).and_raise(StandardError, 'Assignment failed')
|
||||
|
||||
expect do
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
end.to raise_error(StandardError, 'Assignment failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when database connection fails' do
|
||||
it 'raises error for retry' do
|
||||
allow(Conversation).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished)
|
||||
|
||||
expect do
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
end.to raise_error(ActiveRecord::ConnectionNotEstablished)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'concurrency and idempotency' do
|
||||
it 'handles concurrent job execution safely' do
|
||||
# Create multiple jobs for same inbox
|
||||
jobs = []
|
||||
3.times { jobs << described_class.new }
|
||||
|
||||
# All should execute without issues
|
||||
expect do
|
||||
jobs.each { |job| job.perform(inbox_id: inbox.id) }
|
||||
end.not_to raise_error
|
||||
end
|
||||
|
||||
it 'is idempotent for conversation assignment' do
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
|
||||
|
||||
# First call assigns
|
||||
expect(service).to receive(:perform_for_conversation).and_return(true)
|
||||
described_class.new.perform(conversation_id: conversation.id)
|
||||
|
||||
# Second call should handle already assigned conversation
|
||||
expect(service).to receive(:perform_for_conversation).and_return(false)
|
||||
expect { described_class.new.perform(conversation_id: conversation.id) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe 'performance considerations' do
|
||||
it 'processes large inbox assignments in batches' do
|
||||
# Create many unassigned conversations
|
||||
create_list(:conversation, 100, inbox: inbox, assignee: nil)
|
||||
|
||||
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
|
||||
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
|
||||
|
||||
service = instance_double(AssignmentV2::AssignmentService)
|
||||
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
|
||||
|
||||
# Service should be called with default limit
|
||||
expect(service).to receive(:perform_bulk_assignment).with(no_args).and_return(50)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -48,13 +48,6 @@ describe AutomationRuleListener do
|
||||
listener.conversation_created(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if conversation has auto_reply in additional_attributes' do
|
||||
conversation.additional_attributes = { 'auto_reply' => true }
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_created(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -172,18 +165,6 @@ describe AutomationRuleListener do
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if message is auto reply email' do
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
email_conversation = create(:conversation, inbox: email_inbox, account: account)
|
||||
email_message = create(:message, conversation: email_conversation, account: account, content_attributes: { email: { auto_reply: true } })
|
||||
email_event = Events::Base.new('message_created', Time.zone.now, { message: email_message })
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
|
||||
listener.message_created(email_event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if conditions do not match based on content' do
|
||||
message.update!(processed_message_content: 'hi', content: "hi\n\nhello")
|
||||
allow(condition_match).to receive(:present?).and_return(false)
|
||||
|
||||
@@ -111,8 +111,7 @@ RSpec.describe Imap::ImapMailbox do
|
||||
let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml') }
|
||||
|
||||
it 'does not create a new conversation' do
|
||||
expect { class_instance.process(auto_reply_mail.mail, channel) }.to change(Conversation, :count)
|
||||
expect(Conversation.last.additional_attributes['auto_reply']).to be true
|
||||
expect { class_instance.process(auto_reply_mail.mail, channel) }.not_to change(Conversation, :count)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -121,8 +120,6 @@ RSpec.describe Imap::ImapMailbox do
|
||||
|
||||
it 'processes the bounced email' do
|
||||
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
|
||||
expect(Message.last.content_attributes['email']['auto_reply']).to be true
|
||||
expect(Conversation.last.additional_attributes['auto_reply']).to be true
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ RSpec.describe ReplyMailbox do
|
||||
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
|
||||
let(:described_subject) { described_class.receive reply_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
|
||||
auto_reply]
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to]
|
||||
end
|
||||
|
||||
context 'with reply uuid present' do
|
||||
|
||||
@@ -56,7 +56,7 @@ RSpec.describe SupportMailbox do
|
||||
let(:described_subject) { described_class.receive support_mail }
|
||||
let(:serialized_attributes) do
|
||||
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
|
||||
text_content to auto_reply]
|
||||
text_content to]
|
||||
end
|
||||
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentPolicy, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { assignment_policy }
|
||||
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(255) }
|
||||
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:fair_distribution_limit) }
|
||||
it { is_expected.to validate_numericality_of(:fair_distribution_limit).is_greater_than(0).is_less_than_or_equal_to(100) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:fair_distribution_window) }
|
||||
it { is_expected.to validate_numericality_of(:fair_distribution_window).is_greater_than(60).is_less_than_or_equal_to(86_400) }
|
||||
|
||||
context 'with balanced assignment validation' do
|
||||
let(:enterprise_account) { create(:account) }
|
||||
|
||||
before do
|
||||
allow(enterprise_account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'allows balanced assignment for enterprise accounts' do
|
||||
policy = build(:assignment_policy, account: enterprise_account, assignment_order: :balanced)
|
||||
expect(policy).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects balanced assignment for non-enterprise accounts' do
|
||||
policy = build(:assignment_policy, account: account, assignment_order: :balanced)
|
||||
expect(policy).not_to be_valid
|
||||
expect(policy.errors[:assignment_order]).to include('Balanced assignment is only available for enterprise accounts')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0, balanced: 1) }
|
||||
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
|
||||
|
||||
it 'filters enabled policies' do
|
||||
expect(described_class.enabled).to include(enabled_policy)
|
||||
expect(described_class.enabled).not_to include(disabled_policy)
|
||||
end
|
||||
|
||||
it 'filters disabled policies' do
|
||||
expect(described_class.disabled).to include(disabled_policy)
|
||||
expect(described_class.disabled).not_to include(enabled_policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_use_balanced_assignment?' do
|
||||
context 'when account has enterprise agent capacity feature' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(assignment_policy.can_use_balanced_assignment?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not have enterprise features' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(assignment_policy.can_use_balanced_assignment?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webhook_data' do
|
||||
it 'returns correct data structure' do
|
||||
data = assignment_policy.webhook_data
|
||||
|
||||
expect(data).to include(
|
||||
id: assignment_policy.id,
|
||||
name: assignment_policy.name,
|
||||
description: assignment_policy.description,
|
||||
assignment_order: assignment_policy.assignment_order,
|
||||
conversation_priority: assignment_policy.conversation_priority,
|
||||
fair_distribution_limit: assignment_policy.fair_distribution_limit,
|
||||
fair_distribution_window: assignment_policy.fair_distribution_window,
|
||||
enabled: assignment_policy.enabled
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cache invalidation' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
before { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
it 'clears assignment caches on update' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
assignment_policy.update!(name: 'Updated Policy')
|
||||
end
|
||||
|
||||
it 'clears assignment caches on destroy' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
assignment_policy.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,165 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InboxAssignmentPolicy, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:inbox) }
|
||||
it { is_expected.to belong_to(:assignment_policy) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { inbox_assignment_policy }
|
||||
|
||||
it { is_expected.to validate_uniqueness_of(:inbox_id) }
|
||||
|
||||
context 'with inbox and policy from different accounts' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_policy) { create(:assignment_policy, account: other_account) }
|
||||
|
||||
it 'validates inbox belongs to same account as policy' do
|
||||
# Build without the factory callback that sets the accounts to be the same
|
||||
invalid_policy = described_class.new(inbox: inbox, assignment_policy: other_policy)
|
||||
|
||||
expect(invalid_policy).not_to be_valid
|
||||
expect(invalid_policy.errors[:inbox]).to include('must belong to the same account as the assignment policy')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delegations' do
|
||||
it 'delegates account to inbox' do
|
||||
expect(inbox_assignment_policy.account).to eq(account)
|
||||
end
|
||||
|
||||
it 'delegates policy attributes' do
|
||||
expect(inbox_assignment_policy.policy_name).to eq(assignment_policy.name)
|
||||
expect(inbox_assignment_policy.policy_description).to eq(assignment_policy.description)
|
||||
expect(inbox_assignment_policy.policy_assignment_order).to eq(assignment_policy.assignment_order)
|
||||
expect(inbox_assignment_policy.policy_conversation_priority).to eq(assignment_policy.conversation_priority)
|
||||
expect(inbox_assignment_policy.policy_fair_distribution_limit).to eq(assignment_policy.fair_distribution_limit)
|
||||
expect(inbox_assignment_policy.policy_fair_distribution_window).to eq(assignment_policy.fair_distribution_window)
|
||||
expect(inbox_assignment_policy.policy_enabled?).to eq(assignment_policy.enabled?)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
|
||||
let(:inbox2) { create(:inbox, account: account) }
|
||||
let!(:enabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: enabled_policy) }
|
||||
let!(:disabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: disabled_policy) }
|
||||
|
||||
describe '.enabled' do
|
||||
it 'returns only inbox policies with enabled assignment policies' do
|
||||
expect(described_class.enabled).to include(enabled_inbox_policy)
|
||||
expect(described_class.enabled).not_to include(disabled_inbox_policy)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.disabled' do
|
||||
it 'returns only inbox policies with disabled assignment policies' do
|
||||
expect(described_class.disabled).to include(disabled_inbox_policy)
|
||||
expect(described_class.disabled).not_to include(enabled_inbox_policy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webhook_data' do
|
||||
it 'returns correct data structure' do
|
||||
data = inbox_assignment_policy.webhook_data
|
||||
|
||||
expect(data).to include(
|
||||
id: inbox_assignment_policy.id,
|
||||
inbox_id: inbox.id,
|
||||
assignment_policy_id: assignment_policy.id
|
||||
)
|
||||
|
||||
expect(data[:policy]).to eq(assignment_policy.webhook_data)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cache management' do
|
||||
it 'clears inbox cache on create' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
|
||||
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
end
|
||||
|
||||
it 'clears inbox cache on update' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
|
||||
|
||||
inbox_assignment_policy.update!(updated_at: Time.current)
|
||||
end
|
||||
|
||||
it 'clears inbox cache on destroy' do
|
||||
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
|
||||
|
||||
inbox_assignment_policy.destroy!
|
||||
end
|
||||
|
||||
it 'updates account cache' do
|
||||
# AccountCacheRevalidator concern should trigger cache update
|
||||
expect(inbox_assignment_policy).to receive(:update_account_cache)
|
||||
|
||||
inbox_assignment_policy.send(:clear_inbox_cache)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'business logic constraints' do
|
||||
it 'prevents multiple policies per inbox' do
|
||||
# Ensure first policy exists
|
||||
inbox_assignment_policy
|
||||
|
||||
policy2 = create(:assignment_policy, account: account)
|
||||
|
||||
# Try to create a second policy for the same inbox
|
||||
duplicate_policy = described_class.new(inbox: inbox, assignment_policy: policy2)
|
||||
expect(duplicate_policy).not_to be_valid
|
||||
expect(duplicate_policy.errors[:inbox_id]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'allows reassigning to different policy' do
|
||||
policy2 = create(:assignment_policy, account: account)
|
||||
|
||||
expect do
|
||||
inbox_assignment_policy.update!(assignment_policy: policy2)
|
||||
end.not_to raise_error
|
||||
|
||||
expect(inbox_assignment_policy.reload.assignment_policy).to eq(policy2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
it 'handles nil associations gracefully' do
|
||||
# Build without saving to test nil handling
|
||||
policy = build(:inbox_assignment_policy, inbox: nil, assignment_policy: nil)
|
||||
|
||||
expect { policy.valid? }.not_to raise_error
|
||||
expect(policy).not_to be_valid
|
||||
end
|
||||
|
||||
it 'handles policy deletion cascade' do
|
||||
inbox_policy_id = inbox_assignment_policy.id
|
||||
|
||||
# Deleting policy should delete inbox assignment
|
||||
assignment_policy.destroy!
|
||||
|
||||
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
|
||||
end
|
||||
|
||||
it 'handles inbox deletion cascade' do
|
||||
inbox_policy_id = inbox_assignment_policy.id
|
||||
|
||||
# Deleting inbox should delete inbox assignment
|
||||
inbox.destroy!
|
||||
|
||||
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -548,69 +548,4 @@ RSpec.describe Message do
|
||||
expect(presenter).to have_received(:outgoing_content)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#auto_reply_email?' do
|
||||
context 'when message is not an incoming email and inbox is not email' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message) { create(:message, conversation: conversation, message_type: :outgoing) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(message.auto_reply_email?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is an incoming email' do
|
||||
let(:email_channel) { create(:channel_email) }
|
||||
let(:email_inbox) { create(:inbox, channel: email_channel) }
|
||||
let(:conversation) { create(:conversation, inbox: email_inbox) }
|
||||
|
||||
it 'returns false when auto_reply is not set to true' do
|
||||
message = create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content_attributes: {}
|
||||
)
|
||||
expect(message.auto_reply_email?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when auto_reply is set to true' do
|
||||
message = create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content_attributes: { email: { auto_reply: true } }
|
||||
)
|
||||
expect(message.auto_reply_email?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is email' do
|
||||
let(:email_channel) { create(:channel_email) }
|
||||
let(:email_inbox) { create(:inbox, channel: email_channel) }
|
||||
let(:conversation) { create(:conversation, inbox: email_inbox) }
|
||||
|
||||
it 'returns false when auto_reply is not set to true' do
|
||||
message = create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
content_attributes: {}
|
||||
)
|
||||
expect(message.auto_reply_email?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when auto_reply is set to true' do
|
||||
message = create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
content_attributes: { email: { auto_reply: true } }
|
||||
)
|
||||
expect(message.auto_reply_email?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,15 +49,13 @@ RSpec.describe MailPresenter do
|
||||
:references,
|
||||
:subject,
|
||||
:text_content,
|
||||
:to,
|
||||
:auto_reply
|
||||
:to
|
||||
])
|
||||
expect(data[:content_type]).to include('multipart/alternative')
|
||||
expect(data[:date].to_s).to eq('2020-04-20T04:20:20-04:00')
|
||||
expect(data[:message_id]).to eq(mail.message_id)
|
||||
expect(data[:multipart]).to be(true)
|
||||
expect(data[:subject]).to eq(decorated_mail.subject)
|
||||
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
|
||||
end
|
||||
|
||||
it 'give email from in downcased format' do
|
||||
@@ -138,11 +136,6 @@ RSpec.describe MailPresenter do
|
||||
expect(decorated_auto_reply_mail.auto_reply?).to be true
|
||||
expect(decorated_auto_reply_with_auto_submitted_mail.auto_reply?).to be true
|
||||
end
|
||||
|
||||
it 'includes auto_reply status in serialized_data' do
|
||||
expect(decorated_auto_reply_mail.serialized_data[:auto_reply]).to be true
|
||||
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::AssignmentService do
|
||||
before do
|
||||
# Mock the GlobalConfig to avoid InstallationConfig issues
|
||||
allow(GlobalConfig).to receive(:get).and_return({})
|
||||
|
||||
# Define the constant if not already defined
|
||||
stub_const('ASSIGNEE_CHANGED', 'assignee.changed') unless defined?(ASSIGNEE_CHANGED)
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
create(:inbox_member, inbox: inbox, user: agent3)
|
||||
|
||||
# Mock available agents to return inbox members
|
||||
online_members = InboxMember.joins(:user).where(inbox: inbox, user: [agent1, agent2])
|
||||
allow(inbox).to receive(:available_agents).and_return(online_members)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
|
||||
let(:service) { described_class.new(inbox: inbox) }
|
||||
|
||||
# Create agents
|
||||
let!(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let!(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let!(:agent3) { create(:user, account: account, role: :agent, availability: :offline) }
|
||||
|
||||
# Make agents members of inbox
|
||||
|
||||
describe '#perform_for_conversation' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
context 'when policy is enabled' do
|
||||
before do
|
||||
# Mock the selector to return an agent
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent).and_return(agent1)
|
||||
end
|
||||
|
||||
it 'assigns conversation to an available agent' do
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
|
||||
it 'dispatches assignment event' do
|
||||
# The dispatcher is called from the assignment service and also from conversation model
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
service.perform_for_conversation(conversation)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'assignee.changed',
|
||||
anything,
|
||||
hash_including(conversation: conversation, user: agent1)
|
||||
).at_least(:once)
|
||||
end
|
||||
|
||||
it 'returns false when no agents are available' do
|
||||
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is disabled' do
|
||||
before { assignment_policy.update!(enabled: false) }
|
||||
|
||||
it 'does not assign conversation' do
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is already assigned' do
|
||||
before { conversation.update!(assignee: agent1) }
|
||||
|
||||
it 'does not reassign conversation' do
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with round robin assignment' do
|
||||
before do
|
||||
assignment_policy.update!(assignment_order: :round_robin)
|
||||
|
||||
# Mock round robin selector to return agents in rotation
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
agent_index = 0
|
||||
allow(selector).to receive(:select_agent) do
|
||||
agent = [agent1, agent2][agent_index % 2]
|
||||
agent_index += 1
|
||||
agent
|
||||
end
|
||||
end
|
||||
|
||||
it 'assigns agents in rotation' do
|
||||
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
|
||||
|
||||
assignments = conversations.map do |conv|
|
||||
service.perform_for_conversation(conv)
|
||||
conv.reload.assignee
|
||||
end
|
||||
|
||||
# Should rotate between available agents
|
||||
expect(assignments[0]).to eq(agent1)
|
||||
expect(assignments[1]).to eq(agent2)
|
||||
expect(assignments[2]).to eq(agent1) # Back to first agent
|
||||
expect(assignments[3]).to eq(agent2) # Back to second agent
|
||||
end
|
||||
end
|
||||
|
||||
context 'with balanced assignment' do
|
||||
before do
|
||||
# For now, just use round robin since balanced is enterprise only
|
||||
# The test is verifying the service works, not the specific algorithm
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent).and_return(agent2)
|
||||
end
|
||||
|
||||
it 'assigns conversations successfully' do
|
||||
# Create existing assignments
|
||||
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
|
||||
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.perform_for_conversation(new_conversation)).to be true
|
||||
expect(new_conversation.reload.assignee).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'handles different conversation statuses' do
|
||||
# Create resolved conversations (should not count)
|
||||
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :resolved)
|
||||
|
||||
# Create open conversation
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
|
||||
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.perform_for_conversation(new_conversation)).to be true
|
||||
expect(new_conversation.reload.assignee).to eq(agent2) # Selected by mock
|
||||
end
|
||||
end
|
||||
|
||||
context 'when error occurs' do
|
||||
before do
|
||||
# Mock the selector to return an agent
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent).and_return(agent1)
|
||||
end
|
||||
|
||||
it 'returns false and logs error on assignment failure' do
|
||||
allow(conversation).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(conversation))
|
||||
expect(Rails.logger).to receive(:error).with(/Failed to assign conversation/)
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform_bulk_assignment' do
|
||||
before do
|
||||
create_list(:conversation, 5, inbox: inbox, assignee: nil, status: :open)
|
||||
|
||||
# Mock the selector to return agents
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
call_count = 0
|
||||
allow(selector).to receive(:select_agent) do
|
||||
call_count += 1
|
||||
call_count.odd? ? agent1 : agent2
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is enabled' do
|
||||
it 'assigns multiple conversations' do
|
||||
assigned_count = service.perform_bulk_assignment(limit: 3)
|
||||
|
||||
expect(assigned_count).to eq(3)
|
||||
expect(inbox.conversations.unassigned.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'respects conversation priority order' do
|
||||
# Clear existing conversations first
|
||||
Conversation.destroy_all
|
||||
|
||||
# Create conversations with different timestamps
|
||||
old_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.hour.ago)
|
||||
new_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.minute.ago)
|
||||
|
||||
assignment_policy.update!(conversation_priority: :earliest_created)
|
||||
|
||||
# Re-create service after policy change
|
||||
service_with_priority = described_class.new(inbox: inbox)
|
||||
|
||||
service_with_priority.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(old_conversation.reload.assignee).not_to be_nil
|
||||
expect(new_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'handles longest_waiting priority' do
|
||||
# Clear existing conversations first
|
||||
Conversation.destroy_all
|
||||
|
||||
# Create conversations with different last activity
|
||||
inactive_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 2.hours.ago)
|
||||
active_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 5.minutes.ago)
|
||||
|
||||
assignment_policy.update!(conversation_priority: :longest_waiting)
|
||||
|
||||
# Re-create service after policy change
|
||||
service_with_priority = described_class.new(inbox: inbox)
|
||||
|
||||
service_with_priority.perform_bulk_assignment(limit: 1)
|
||||
|
||||
expect(inactive_conversation.reload.assignee).not_to be_nil
|
||||
expect(active_conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'returns 0 when no conversations to assign' do
|
||||
Conversation.find_each { |c| c.update!(assignee_id: agent1.id) }
|
||||
|
||||
expect(service.perform_bulk_assignment).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when policy is disabled' do
|
||||
before { assignment_policy.update!(enabled: false) }
|
||||
|
||||
it 'does not assign any conversations' do
|
||||
expect(service.perform_bulk_assignment).to eq(0)
|
||||
expect(inbox.conversations.unassigned.count).to eq(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'enterprise capacity features' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
# Mock enterprise availability
|
||||
stub_const('Enterprise', Module.new)
|
||||
stub_const('Enterprise::AssignmentV2::CapacityManager', Class.new)
|
||||
|
||||
# Mock the selector to return agent1
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent).and_return(agent1)
|
||||
end
|
||||
|
||||
it 'uses round robin when enterprise features are available' do
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
|
||||
it 'handles absence of enterprise features gracefully' do
|
||||
# Remove enterprise constant
|
||||
hide_const('Enterprise')
|
||||
|
||||
# Service should still work with round robin
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'cache management' do
|
||||
it 'uses cache for round robin state' do
|
||||
assignment_policy.update!(assignment_order: :round_robin)
|
||||
|
||||
# Mock the selector and round robin service
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent).and_return(agent1)
|
||||
|
||||
# Create and assign conversations
|
||||
conversation1 = create(:conversation, inbox: inbox, assignee: nil)
|
||||
service.perform_for_conversation(conversation1)
|
||||
|
||||
conversation2 = create(:conversation, inbox: inbox, assignee: nil)
|
||||
service.perform_for_conversation(conversation2)
|
||||
|
||||
# Just verify assignments worked
|
||||
expect(conversation1.reload.assignee).to eq(agent1)
|
||||
expect(conversation2.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
it 'handles inbox without policy gracefully' do
|
||||
inbox_assignment_policy.destroy!
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
end
|
||||
|
||||
it 'handles empty agent list' do
|
||||
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be false
|
||||
end
|
||||
|
||||
it 'filters out agents without inbox membership' do
|
||||
non_member_agent = create(:user, account: account, role: :agent, availability: :online)
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil)
|
||||
|
||||
# Mock selector to return agent1 (who is a member)
|
||||
selector = instance_double(AssignmentV2::RoundRobinSelector)
|
||||
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent).and_return(agent1)
|
||||
|
||||
expect(service.perform_for_conversation(conversation)).to be true
|
||||
expect(conversation.reload.assignee).not_to eq(non_member_agent)
|
||||
expect(conversation.reload.assignee).to eq(agent1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,293 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::RateLimiter, type: :service do
|
||||
before do
|
||||
# Mock GlobalConfig to avoid InstallationConfig issues
|
||||
allow(GlobalConfig).to receive(:get).and_return({})
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
redis.flushdb if Rails.env.test?
|
||||
|
||||
# Ensure inbox_assignment_policy exists so the inbox has a policy
|
||||
inbox_assignment_policy
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 3600) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: policy) }
|
||||
let(:rate_limiter) { described_class.new(inbox: inbox, user: agent) }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets up rate limiter with inbox and user' do
|
||||
expect(rate_limiter.instance_variable_get(:@inbox)).to eq(inbox)
|
||||
expect(rate_limiter.instance_variable_get(:@user)).to eq(agent)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#within_limits?' do
|
||||
context 'when agent has no assignments in current window' do
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.within_limits?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is below limit' do
|
||||
before do
|
||||
# Simulate 3 assignments in current window
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(rate_limiter.within_limits?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent reaches limit' do
|
||||
before do
|
||||
# Simulate reaching the limit (5 assignments)
|
||||
5.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(rate_limiter.within_limits?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent exceeds limit' do
|
||||
before do
|
||||
# Simulate exceeding the limit
|
||||
6.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(rate_limiter.within_limits?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#record_assignment' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox) }
|
||||
|
||||
it 'increments assignment count for agent' do
|
||||
initial_status = rate_limiter.status
|
||||
rate_limiter.record_assignment(conversation)
|
||||
new_status = rate_limiter.status
|
||||
|
||||
expect(new_status[:current_count]).to eq(initial_status[:current_count] + 1)
|
||||
end
|
||||
|
||||
it 'sets expiration on the key' do
|
||||
rate_limiter.record_assignment(conversation)
|
||||
|
||||
current_window = (Time.current.to_i / 3600) * 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
ttl = redis.ttl(key)
|
||||
expect(ttl).to be > 0
|
||||
expect(ttl).to be <= 3600
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
redis_double = instance_double(Redis)
|
||||
allow(Redis).to receive(:new).and_return(redis_double)
|
||||
allow(redis_double).to receive(:multi).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises error' do
|
||||
expect { rate_limiter.record_assignment(conversation) }.to raise_error(Redis::ConnectionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#status' do
|
||||
it 'returns correct status for agent with no assignments' do
|
||||
status = rate_limiter.status
|
||||
expect(status[:current_count]).to eq(0)
|
||||
expect(status[:within_limits]).to be true
|
||||
expect(status[:limit]).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns correct count after assignments' do
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:current_count]).to eq(3)
|
||||
expect(status[:within_limits]).to be true
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
redis_double = instance_double(Redis)
|
||||
allow(Redis).to receive(:new).and_return(redis_double)
|
||||
allow(redis_double).to receive(:get).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises error' do
|
||||
expect { rate_limiter.status }.to raise_error(Redis::ConnectionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'remaining assignments' do
|
||||
it 'returns full limit when no assignments made' do
|
||||
status = rate_limiter.status
|
||||
expect(status[:limit] - status[:current_count]).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns correct remaining count' do
|
||||
2.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:limit] - status[:current_count]).to eq(3)
|
||||
end
|
||||
|
||||
it 'returns 0 when limit reached' do
|
||||
5.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:limit] - status[:current_count]).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns negative when limit exceeded' do
|
||||
6.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
status = rate_limiter.status
|
||||
expect(status[:limit] - status[:current_count]).to eq(-1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment capacity checks' do
|
||||
it 'returns true when agent has remaining capacity' do
|
||||
2.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
expect(rate_limiter.within_limits?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when agent has no capacity' do
|
||||
5.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
expect(rate_limiter.within_limits?).to be false
|
||||
end
|
||||
|
||||
it 'correctly tracks multiple assignments' do
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
|
||||
status = rate_limiter.status
|
||||
expect(status[:current_count]).to eq(3)
|
||||
expect(status[:within_limits]).to be true
|
||||
expect(status[:limit] - status[:current_count]).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'multiple agents' do
|
||||
let(:agent2) { create(:user, account: account) }
|
||||
let(:rate_limiter2) { described_class.new(inbox: inbox, user: agent2) }
|
||||
|
||||
before do
|
||||
2.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
4.times { rate_limiter2.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'tracks status independently for each agent' do
|
||||
status1 = rate_limiter.status
|
||||
status2 = rate_limiter2.status
|
||||
|
||||
expect(status1[:current_count]).to eq(2)
|
||||
expect(status1[:within_limits]).to be true
|
||||
expect(status1[:limit] - status1[:current_count]).to eq(3)
|
||||
|
||||
expect(status2[:current_count]).to eq(4)
|
||||
expect(status2[:within_limits]).to be true
|
||||
expect(status2[:limit] - status2[:current_count]).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'reset functionality' do
|
||||
before do
|
||||
3.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
end
|
||||
|
||||
it 'can be reset by clearing Redis key' do
|
||||
status_before = rate_limiter.status
|
||||
expect(status_before[:current_count]).to eq(3)
|
||||
|
||||
# Manually clear the key
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
current_window = (Time.current.to_i / 3600) * 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
redis.del(key)
|
||||
|
||||
status_after = rate_limiter.status
|
||||
expect(status_after[:current_count]).to eq(0)
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
redis_double = instance_double(Redis)
|
||||
allow(Redis).to receive(:new).and_return(redis_double)
|
||||
allow(redis_double).to receive(:del).and_raise(Redis::ConnectionError)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises error' do
|
||||
redis = Redis.new(Redis::Config.app)
|
||||
current_window = (Time.current.to_i / 3600) * 3600
|
||||
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
|
||||
expect { redis.del(key) }.to raise_error(Redis::ConnectionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'window timing' do
|
||||
it 'calculates reset time correctly' do
|
||||
# Mock current time to make test predictable
|
||||
travel_to(Time.zone.parse('2024-01-01 10:30:00')) do
|
||||
status = rate_limiter.status
|
||||
reset_time = status[:reset_at]
|
||||
|
||||
expect(reset_time).to be_a(Time)
|
||||
expect(reset_time).to be > Time.current
|
||||
expect(reset_time - Time.current).to be <= 3600
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'window boundaries' do
|
||||
it 'resets count in new window' do
|
||||
# Set up assignment in current window
|
||||
2.times { rate_limiter.record_assignment(create(:conversation)) }
|
||||
expect(rate_limiter.status[:current_count]).to eq(2)
|
||||
|
||||
# Travel to next window (advance by window size)
|
||||
travel(3601.seconds) do
|
||||
expect(rate_limiter.status[:current_count]).to eq(0)
|
||||
expect(rate_limiter.within_limits?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'concurrent access' do
|
||||
it 'handles concurrent increments correctly' do
|
||||
threads = []
|
||||
results = []
|
||||
mutex = Mutex.new
|
||||
|
||||
# Simulate concurrent assignment requests
|
||||
5.times do
|
||||
threads << Thread.new do
|
||||
within_limits = rate_limiter.within_limits?
|
||||
mutex.synchronize { results << within_limits }
|
||||
rate_limiter.record_assignment(create(:conversation)) if within_limits
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# Final count should not exceed the limit
|
||||
final_count = rate_limiter.status[:current_count]
|
||||
expect(final_count).to be <= 5
|
||||
expect(results.count(true)).to eq(final_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,145 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentV2::RoundRobinSelector, type: :service do
|
||||
before do
|
||||
# Mock GlobalConfig to avoid InstallationConfig issues
|
||||
allow(GlobalConfig).to receive(:get).and_return({})
|
||||
create(:inbox_member, inbox: inbox, user: user1)
|
||||
create(:inbox_member, inbox: inbox, user: user2)
|
||||
create(:inbox_member, inbox: inbox, user: user3)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:policy) { create(:assignment_policy, account: account) }
|
||||
let(:user1) { create(:user, account: account, availability: :online) }
|
||||
let(:user2) { create(:user, account: account, availability: :online) }
|
||||
let(:user3) { create(:user, account: account, availability: :offline) }
|
||||
|
||||
describe '#select_agent' do
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
|
||||
|
||||
before do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
|
||||
end
|
||||
|
||||
context 'when Redis is available' do
|
||||
before do
|
||||
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user1.id.to_s)
|
||||
end
|
||||
|
||||
it 'returns an online agent' do
|
||||
result = selector.select_agent(available_agents)
|
||||
expect(result).to eq(user1)
|
||||
end
|
||||
|
||||
it 'excludes offline agents' do
|
||||
result = selector.select_agent(available_agents)
|
||||
expect(result).not_to eq(user3)
|
||||
end
|
||||
|
||||
it 'handles no available agent gracefully' do
|
||||
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(nil)
|
||||
result = selector.select_agent(available_agents)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Redis fails' do
|
||||
before do
|
||||
allow(round_robin_service).to receive(:available_agent).and_raise(Redis::CannotConnectError)
|
||||
end
|
||||
|
||||
it 'raises the error' do
|
||||
expect { selector.select_agent(available_agents) }.to raise_error(Redis::CannotConnectError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty available agents' do
|
||||
it 'returns nil when no agents are available' do
|
||||
result = selector.select_agent(InboxMember.none)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different user IDs' do
|
||||
it 'correctly finds the inbox member by user_id' do
|
||||
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user2.id.to_s)
|
||||
|
||||
result = selector.select_agent(available_agents)
|
||||
expect(result).to eq(user2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_agent_to_queue' do
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
|
||||
before do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
|
||||
end
|
||||
|
||||
it 'delegates to round robin service' do
|
||||
expect(round_robin_service).to receive(:add_agent_to_queue).with(user1.id)
|
||||
selector.add_agent_to_queue(user1.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#remove_agent_from_queue' do
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
|
||||
before do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
|
||||
end
|
||||
|
||||
it 'delegates to round robin service' do
|
||||
expect(round_robin_service).to receive(:remove_agent_from_queue).with(user1.id)
|
||||
selector.remove_agent_from_queue(user1.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reset_queue' do
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
|
||||
before do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
|
||||
end
|
||||
|
||||
it 'delegates to round robin service' do
|
||||
expect(round_robin_service).to receive(:reset_queue)
|
||||
selector.reset_queue
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
|
||||
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
|
||||
|
||||
before do
|
||||
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
|
||||
end
|
||||
|
||||
it 'handles invalid user_id from round robin service' do
|
||||
allow(round_robin_service).to receive(:available_agent).and_return('invalid_id')
|
||||
|
||||
result = selector.select_agent(available_agents)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'handles user_id not in available agents' do
|
||||
other_user = create(:user, account: account)
|
||||
allow(round_robin_service).to receive(:available_agent).and_return(other_user.id.to_s)
|
||||
|
||||
result = selector.select_agent(available_agents)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -111,35 +111,6 @@ describe MessageTemplates::HookExecutionService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is an auto reply email' do
|
||||
it 'does not call any template hooks' do
|
||||
contact = create(:contact)
|
||||
conversation = create(:conversation, contact: contact)
|
||||
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
|
||||
|
||||
message = create(:message, conversation: conversation, content_type: :incoming_email)
|
||||
message.content_attributes = { email: { auto_reply: true } }
|
||||
message.save!
|
||||
|
||||
greeting_service = double
|
||||
email_collect_service = double
|
||||
out_of_office_service = double
|
||||
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
|
||||
allow(greeting_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
|
||||
allow(email_collect_service).to receive(:perform).and_return(true)
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is after working hours' do
|
||||
it 'calls ::MessageTemplates::Template::OutOfOffice' do
|
||||
contact = create(:contact)
|
||||
|
||||
@@ -282,51 +282,5 @@ describe Twilio::IncomingMessageService do
|
||||
expect(message.attachments.first.file_type).to eq('location')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ProfileName is provided for WhatsApp' do
|
||||
it 'uses ProfileName as contact name' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello with profile name',
|
||||
ProfileName: 'John Doe'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('John Doe')
|
||||
end
|
||||
|
||||
it 'falls back to formatted phone number when ProfileName is blank' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Hello without profile name',
|
||||
ProfileName: ''
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('1234567890')
|
||||
end
|
||||
|
||||
it 'uses formatted phone number when ProfileName is not provided' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: '+1234567890',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||
Body: 'Regular SMS message'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
|
||||
expect(contact.name).to eq('1234567890')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user