Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-07-02 22:42:41 +05:30
committed by GitHub
26 changed files with 944 additions and 90 deletions
+5
View File
@@ -6,6 +6,11 @@ name: Deploy Check
on:
pull_request:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
deployment_check:
name: Check Deployment
@@ -5,6 +5,11 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
log_lines_check:
runs-on: ubuntu-latest
+5
View File
@@ -5,6 +5,11 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
@@ -1,8 +1,9 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import { ref, computed, watch, nextTick } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useI18n } from 'vue-i18n';
const props = defineProps({
searchKey: {
@@ -13,41 +14,89 @@ const props = defineProps({
const emit = defineEmits(['selectAgent']);
const { t } = useI18n();
const getters = useStoreGetters();
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const teams = useMapGetter('teams/getTeams');
const tagAgentsRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
if (!props.searchKey) {
return agents.value;
}
return agents.value.filter(agent =>
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
const search = props.searchKey?.trim().toLowerCase() || '';
const buildItems = (list, type, infoKey) =>
list
.map(item => ({
...item,
type,
displayName: item.name,
displayInfo: item[infoKey],
}))
.filter(item =>
search ? item.displayName.toLowerCase().includes(search) : true
);
const categories = [
{
title: t('CONVERSATION.MENTION.AGENTS'),
data: buildItems(agents.value, 'user', 'email'),
},
{
title: t('CONVERSATION.MENTION.TEAMS'),
data: buildItems(teams.value, 'team', 'description'),
},
];
return categories.flatMap(({ title, data }) =>
data.length
? [
{ type: 'header', title, id: `${title.toLowerCase()}-header` },
...data,
]
: []
);
});
const selectableItems = computed(() => {
return items.value.filter(item => item.type !== 'header');
});
const getSelectableIndex = item => {
return selectableItems.value.findIndex(
selectableItem =>
selectableItem.type === item.type && selectableItem.id === item.id
);
};
const adjustScroll = () => {
nextTick(() => {
if (tagAgentsRef.value) {
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
const selectedElement = tagAgentsRef.value.querySelector(
`#mention-item-${selectedIndex.value}`
);
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'auto',
});
}
}
});
};
const onSelect = () => {
emit('selectAgent', items.value[selectedIndex.value]);
emit('selectAgent', selectableItems.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items,
items: selectableItems,
onSelect,
adjustScroll,
selectedIndex,
});
watch(items, newListOfAgents => {
watch(selectableItems, newListOfAgents => {
if (newListOfAgents.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
@@ -69,40 +118,61 @@ const onAgentSelect = index => {
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
role="listbox"
>
<li
v-for="(agent, index) in items"
:id="`mention-item-${index}`"
:key="agent.id"
:class="{
'bg-n-alpha-black2': index === selectedIndex,
'last:mb-0': items.length <= 4,
}"
class="flex items-center px-2 py-1 rounded-md"
@click="onAgentSelect(index)"
@mouseover="onHover(index)"
v-for="item in items"
:id="
item.type === 'header'
? undefined
: `mention-item-${getSelectableIndex(item)}`
"
:key="`${item.type}-${item.id}`"
>
<div class="ltr:mr-2 rtl:ml-2">
<Avatar :src="agent.thumbnail" :name="agent.name" rounded-full />
</div>
<!-- Section Header -->
<div
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
v-if="item.type === 'header'"
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
>
<h5
class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
:class="{
'text-n-slate-12': index === selectedIndex,
}"
>
{{ agent.name }}
</h5>
{{ item.title }}
</div>
<!-- Selectable Item -->
<div
v-else
:class="{
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
}"
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
role="option"
@click="onAgentSelect(getSelectableIndex(item))"
@mouseover="onHover(getSelectableIndex(item))"
>
<div class="ltr:mr-2 rtl:ml-2">
<Avatar
:src="item.thumbnail"
:name="item.displayName"
rounded-full
/>
</div>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
:class="{
'text-n-slate-11': index === selectedIndex,
}"
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
>
{{ agent.email }}
<h5
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
:class="{
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayName }}
</h5>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
:class="{
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayInfo }}
</div>
</div>
</div>
</li>
@@ -301,11 +301,18 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention':
return state.schema.nodes.mention.create({
case 'mention': {
const mentionType = content.type || 'user';
const displayName = content.displayName || content.name;
const mentionNode = state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
userFullName: displayName,
mentionType,
});
return mentionNode;
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
@@ -48,6 +48,7 @@ describe('getContentNode', () => {
{
userId: content.id,
userFullName: content.name,
mentionType: 'user',
}
);
});
@@ -8,6 +8,7 @@ import {
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
} from '../editorHelper';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
@@ -18,12 +19,28 @@ const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'text*',
content: 'inline*',
group: 'block',
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
},
text: {
group: 'inline',
toDOM: node => node.text, // Represents text as its actual string value.
},
mention: {
attrs: {
userId: { default: '' },
userFullName: { default: '' },
mentionType: { default: 'user' },
},
inline: true,
group: 'inline',
toDOM: node => [
'span',
{ class: 'mention' },
`@${node.attrs.userFullName}`,
],
},
},
});
@@ -439,3 +456,173 @@ describe('setURLWithQueryAndSize', () => {
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
describe('getContentNode', () => {
let mockEditorView;
beforeEach(() => {
mockEditorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(attrs => ({
type: { name: 'mention' },
attrs,
})),
},
},
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
},
},
};
});
describe('mention node creation', () => {
it('creates a user mention node with correct attributes', () => {
const userContent = {
id: '123',
name: 'John Doe',
type: 'user',
};
const result = getContentNode(mockEditorView, 'mention', userContent, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '123',
userFullName: 'John Doe',
mentionType: 'user',
});
expect(result).toEqual({
node: {
type: { name: 'mention' },
attrs: {
userId: '123',
userFullName: 'John Doe',
mentionType: 'user',
},
},
from: 0,
to: 5,
});
});
it('creates a team mention node with correct attributes', () => {
const teamContent = {
id: '456',
name: 'Support Team',
type: 'team',
};
const result = getContentNode(mockEditorView, 'mention', teamContent, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '456',
userFullName: 'Support Team',
mentionType: 'team',
});
expect(result).toEqual({
node: {
type: { name: 'mention' },
attrs: {
userId: '456',
userFullName: 'Support Team',
mentionType: 'team',
},
},
from: 0,
to: 5,
});
});
it('defaults to user mention type when type is not specified', () => {
const contentWithoutType = {
id: '789',
name: 'Jane Smith',
};
getContentNode(mockEditorView, 'mention', contentWithoutType, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '789',
userFullName: 'Jane Smith',
mentionType: 'user',
});
});
it('uses displayName over name when both are provided', () => {
const contentWithDisplayName = {
id: '101',
name: 'john_doe',
displayName: 'John Doe (Admin)',
type: 'user',
};
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '101',
userFullName: 'John Doe (Admin)',
mentionType: 'user',
});
});
it('handles missing displayName by falling back to name', () => {
const contentWithoutDisplayName = {
id: '102',
name: 'jane_smith',
type: 'user',
};
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '102',
userFullName: 'jane_smith',
mentionType: 'user',
});
});
});
describe('unsupported node types', () => {
it('returns null node for unsupported type', () => {
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
from: 0,
to: 5,
});
expect(result).toEqual({
node: null,
from: 0,
to: 5,
});
});
});
});
@@ -126,6 +126,7 @@ const validateSingleAction = action => {
'snooze_conversation',
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
];
if (
@@ -147,7 +147,8 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -104,7 +104,6 @@ onMounted(() => {
:key="element.id"
:macro="element"
:conversation-id="conversationId"
class="drag-handle cursor-grab"
/>
</template>
</Draggable>
@@ -54,6 +54,7 @@ const closeMacroPreview = () => {
<template>
<div
class="relative flex items-center justify-between leading-4 rounded-md h-10 pl-3 pr-2"
:class="showPreview ? 'cursor-default' : 'drag-handle cursor-grab'"
>
<span
class="overflow-hidden whitespace-nowrap text-ellipsis font-medium text-n-slate-12"
@@ -32,6 +32,30 @@ export const AUTOMATIONS = {
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'status',
name: 'STATUS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'team_id',
name: 'TEAM_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'priority',
name: 'PRIORITY',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
name: 'CONVERSATION_LANGUAGE',
@@ -82,7 +106,10 @@ export const AUTOMATIONS = {
key: 'snooze_conversation',
name: 'SNOOZE_CONVERSATION',
},
{
key: 'open_conversation',
name: 'OPEN_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'RESOLVE_CONVERSATION',
@@ -508,6 +535,11 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'RESOLVE_CONVERSATION',
inputType: null,
},
{
key: 'open_conversation',
label: 'OPEN_CONVERSATION',
inputType: null,
},
{
key: 'send_webhook_event',
label: 'SEND_WEBHOOK_EVENT',
+1 -1
View File
@@ -41,7 +41,7 @@ class AutomationRule < ApplicationRecord
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation snooze_conversation change_priority send_email_transcript].freeze
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript].freeze
end
def file_base_data
+4
View File
@@ -18,6 +18,10 @@ class ActionService
@conversation.resolved!
end
def open_conversation(_params)
@conversation.open!
end
def change_status(status)
@conversation.update!(status: status[0])
end
+2 -1
View File
@@ -73,7 +73,8 @@ class Line::SendOnLineService < Base::SendOnChannelService
contents: [
{
type: 'text',
text: message.content
text: message.content,
wrap: true
},
*input_select_to_button
]
+24 -5
View File
@@ -19,14 +19,33 @@ class Messages::MentionService
end
def mentioned_ids
@mentioned_ids ||= message.content.scan(%r{\(mention://(user|team)/(\d+)/(.+?)\)}).map(&:second).uniq
user_mentions = message.content.scan(%r{\(mention://user/(\d+)/(.+?)\)}).map(&:first)
team_mentions = message.content.scan(%r{\(mention://team/(\d+)/(.+?)\)}).map(&:first)
expanded_user_ids = expand_team_mentions_to_users(team_mentions)
(user_mentions + expanded_user_ids).uniq
end
def expand_team_mentions_to_users(team_ids)
return [] if team_ids.blank?
message.inbox.account.teams
.joins(:team_members)
.where(id: team_ids)
.pluck('team_members.user_id')
.map(&:to_s)
end
def valid_mentionable_user_ids
@valid_mentionable_user_ids ||= begin
inbox = message.inbox
inbox.account.administrators.pluck(:id) + inbox.members.pluck(:id)
end
end
def filter_mentioned_ids_by_inbox
inbox = message.inbox
valid_mentionable_ids = inbox.account.administrators.map(&:id) + inbox.members.map(&:id)
# Intersection of ids
mentioned_ids & valid_mentionable_ids.uniq.map(&:to_s)
mentioned_ids & valid_mentionable_user_ids.map(&:to_s)
end
def generate_notifications_for_mentions(validated_mentioned_ids)
@@ -1,5 +1,7 @@
module Enterprise::Inbox
def member_ids_with_assignment_capacity
return super unless enable_auto_assignment?
max_assignment_limit = auto_assignment_config['max_assignment_limit']
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
super - overloaded_agent_ids
+1 -1
View File
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/prosemirror-schema": "1.1.6-next",
"@chatwoot/utils": "^0.0.47",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
+5 -5
View File
@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.1.1-next
version: 1.1.1-next
specifier: 1.1.6-next
version: 1.1.6-next
'@chatwoot/utils':
specifier: ^0.0.47
version: 0.0.47
@@ -403,8 +403,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.1.1-next':
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
'@chatwoot/prosemirror-schema@1.1.6-next':
resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
'@chatwoot/utils@0.0.47':
resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
@@ -5237,7 +5237,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.1.1-next':
'@chatwoot/prosemirror-schema@1.1.6-next':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -74,4 +74,35 @@ RSpec.describe Conversation, type: :model do
end
end
end
describe 'assignment capacity limits' do
describe 'team assignment with inbox auto-assignment disabled' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: false, auto_assignment_config: { max_assignment_limit: 1 }) }
let(:team) { create(:team, account: account, allow_auto_assign: true) }
let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) }
let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) }
before do
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
create(:team_member, team: team, user: agent1)
create(:team_member, team: team, user: agent2)
# Both agents are over the limit (simulate by assigning open conversations)
create_list(:conversation, 2, inbox: inbox, assignee: agent1, status: :open)
create_list(:conversation, 2, inbox: inbox, assignee: agent2, status: :open)
end
it 'does not enforce max_assignment_limit for team assignment when inbox auto-assignment is disabled' do
conversation = create(:conversation, inbox: inbox, account: account, assignee: nil, status: :open)
# Assign to team to trigger the assignment logic
conversation.update!(team: team)
# Should assign to a team member even if they are over the limit
expect(conversation.reload.assignee).to be_present
expect([agent1, agent2]).to include(conversation.reload.assignee)
end
end
end
end
@@ -171,6 +171,16 @@ describe AutomationRuleListener do
listener.message_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'passes conversation attributes to conditions filter service' do
conversation.update!(status: :open, priority: :high)
listener.message_created(event)
expect(AutomationRules::ConditionsFilterService).to have_received(:new).with(
automation_rule,
conversation,
{ message: message, changed_attributes: { content: %w[nil Hi] } }
)
end
end
end
end
+11
View File
@@ -14,6 +14,17 @@ describe ActionService do
end
end
describe '#open_conversation' do
let(:conversation) { create(:conversation, status: :resolved) }
let(:action_service) { described_class.new(conversation) }
it 'opens the conversation' do
expect(conversation.status).to eq('resolved')
action_service.open_conversation(nil)
expect(conversation.reload.status).to eq('open')
end
end
describe '#change_priority' do
let(:conversation) { create(:conversation) }
let(:action_service) { described_class.new(conversation) }
@@ -110,6 +110,29 @@ RSpec.describe AutomationRules::ConditionsFilterService do
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
end
end
context 'when filtering messages based on conversation attributes' do
let(:conversation) { create(:conversation, account: account, status: :open, priority: :high) }
let(:message) do
create(:message, account: account, conversation: conversation, content: 'Test message',
inbox: conversation.inbox, message_type: :incoming)
end
it 'will return true when conversation status matches' do
rule.update(conditions: [{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }])
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
end
it 'will return false when conversation status does not match' do
rule.update(conditions: [{ 'values': ['resolved'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }])
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
end
it 'will return true when conversation priority matches' do
rule.update(conditions: [{ 'values': ['high'], 'attribute_key': 'priority', 'query_operator': nil, 'filter_operator': 'equal_to' }])
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
end
end
end
end
end
@@ -112,7 +112,8 @@ describe Line::SendOnLineService do
contents: [
{
type: 'text',
text: 'test'
text: 'test',
wrap: true
},
{
type: 'button',
+466 -32
View File
@@ -5,69 +5,503 @@ describe Messages::MentionService do
let!(:user) { create(:user, account: account) }
let!(:first_agent) { create(:user, account: account) }
let!(:second_agent) { create(:user, account: account) }
let!(:third_agent) { create(:user, account: account) }
let!(:admin_user) { create(:user, account: account, role: :administrator) }
let!(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let!(:team) { create(:team, account: account, name: 'Support Team') }
let!(:empty_team) { create(:team, account: account, name: 'Empty Team') }
let(:builder) { double }
before do
create(:inbox_member, user: first_agent, inbox: inbox)
create(:inbox_member, user: second_agent, inbox: inbox)
create(:team_member, user: first_agent, team: team)
create(:team_member, user: second_agent, team: team)
conversation.reload
allow(NotificationBuilder).to receive(:new).and_return(builder)
allow(builder).to receive(:perform)
allow(Conversations::UserMentionJob).to receive(:perform_later)
end
context 'when message contains mention' do
it 'creates notifications for inbox member who was mentioned' do
describe '#perform' do
context 'when message is not private' do
it 'does not process mentions for public messages' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
private: false
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when message has no content' do
it 'does not process mentions for empty messages' do
message = build(
:message,
conversation: conversation,
account: account,
content: nil,
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when message has no mentions' do
it 'does not process messages without mentions' do
message = build(
:message,
conversation: conversation,
account: account,
content: 'just a regular message',
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
end
describe 'user mentions' do
context 'when message contains single user mention' do
it 'creates notifications for inbox member who was mentioned' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
[first_agent.id.to_s],
conversation.id,
account.id
)
end
it 'adds mentioned user as conversation participant' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to include(first_agent.id)
end
end
context 'when message contains multiple user mentions' do
let(:message) do
build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{second_agent.id}/#{second_agent.name}) " \
"and (mention://user/#{first_agent.id}/#{first_agent.name}), please look into this?",
private: true
)
end
it 'creates notifications for all mentioned inbox members' do
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
it 'adds all mentioned users to the participants list' do
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
end
it 'passes unique user IDs to UserMentionJob' do
described_class.new(message: message).perform
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
conversation.id,
account.id
)
end
end
context 'when mentioned user is not an inbox member' do
let!(:non_member_user) { create(:user, account: account) }
it 'does not create notifications for non-inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{non_member_user.id}/#{non_member_user.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when mentioned user is an admin' do
it 'creates notifications for admin users even if not inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{admin_user.id}/#{admin_user.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: admin_user,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
end
context 'when same user is mentioned multiple times' do
it 'creates only one notification per user' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name}) and again (mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).once
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
[first_agent.id.to_s],
conversation.id,
account.id
)
end
end
end
describe 'team mentions' do
context 'when message contains single team mention' do
it 'creates notifications for all team members who are inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
it 'adds all team members as conversation participants' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
end
it 'passes team member IDs to UserMentionJob' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
conversation.id,
account.id
)
end
end
context 'when team has members who are not inbox members' do
let!(:non_inbox_team_member) { create(:user, account: account) }
before do
create(:team_member, user: non_inbox_team_member, team: team)
end
it 'only notifies team members who are also inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: first_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: second_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).not_to have_received(:new).with(
notification_type: 'conversation_mention', user: non_inbox_team_member, account: account,
primary_actor: message.conversation, secondary_actor: message
)
end
end
context 'when team has admin members' do
before do
create(:team_member, user: admin_user, team: team)
end
it 'includes admin team members in notifications' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: admin_user,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
end
context 'when team is empty' do
it 'does not create any notifications for empty teams' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{empty_team.id}/#{empty_team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when team does not exist' do
it 'does not create notifications for non-existent teams' do
message = build(
:message,
conversation: conversation,
account: account,
content: 'hey (mention://team/99999/NonExistentTeam) please help',
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when same team is mentioned multiple times' do
it 'creates only one notification per team member' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) and again (mention://team/#{team.id}/#{team.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).exactly(2).times
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
conversation.id,
account.id
)
end
end
end
describe 'mixed user and team mentions' do
context 'when message contains both user and team mentions' do
it 'creates notifications for both individual users and team members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{third_agent.id}/#{third_agent.name}) and (mention://team/#{team.id}/#{team.name})",
private: true
)
# Make third_agent an inbox member
create(:inbox_member, user: third_agent, inbox: inbox)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: third_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: first_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: second_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
end
it 'avoids duplicate notifications when user is mentioned directly and via team' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://team/#{team.id}/#{team.name})",
private: true
)
described_class.new(message: message).perform
# first_agent should only receive one notification despite being mentioned directly and via team
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).once
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).once
end
end
end
describe 'cross-account validation' do
let!(:other_account) { create(:account) }
let!(:other_team) { create(:team, account: other_account) }
let!(:other_user) { create(:user, account: other_account) }
before do
create(:team_member, user: other_user, team: other_team)
end
it 'does not process mentions for teams from other accounts' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
content: "hey (mention://team/#{other_team.id}/#{other_team.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when message contains multiple mentions' do
let(:message) do
build(
it 'does not process mentions for users from other accounts' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey [#{second_agent.name}](mention://user/#{second_agent.id}/#{second_agent.name})/
[#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name}),
please look in to this?",
content: "hey (mention://user/#{other_user.id}/#{other_user.name})",
private: true
)
end
it 'creates notifications for inbox member who was mentioned' do
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message)
end
it 'add the users to the participants list' do
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
end