mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 04:27:53 +00:00
feat: Add the ability to mention team in private message (#11758)
This PR allows agents to mention entire teams in private messages using `@team_name` syntax. When a team is mentioned, all team members with inbox access are automatically notified. The scheme changes can be found [here](https://github.com/chatwoot/prosemirror-schema/pull/34). --------- Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import { ref, computed, watch, nextTick } from '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 { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
searchKey: {
|
searchKey: {
|
||||||
@@ -13,41 +14,89 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['selectAgent']);
|
const emit = defineEmits(['selectAgent']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
|
||||||
const tagAgentsRef = ref(null);
|
const tagAgentsRef = ref(null);
|
||||||
const selectedIndex = ref(0);
|
const selectedIndex = ref(0);
|
||||||
|
|
||||||
const items = computed(() => {
|
const items = computed(() => {
|
||||||
if (!props.searchKey) {
|
const search = props.searchKey?.trim().toLowerCase() || '';
|
||||||
return agents.value;
|
|
||||||
}
|
const buildItems = (list, type, infoKey) =>
|
||||||
return agents.value.filter(agent =>
|
list
|
||||||
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
|
.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 = () => {
|
const adjustScroll = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (tagAgentsRef.value) {
|
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 = () => {
|
const onSelect = () => {
|
||||||
emit('selectAgent', items.value[selectedIndex.value]);
|
emit('selectAgent', selectableItems.value[selectedIndex.value]);
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
items,
|
items: selectableItems,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(items, newListOfAgents => {
|
watch(selectableItems, newListOfAgents => {
|
||||||
if (newListOfAgents.length < selectedIndex.value + 1) {
|
if (newListOfAgents.length < selectedIndex.value + 1) {
|
||||||
selectedIndex.value = 0;
|
selectedIndex.value = 0;
|
||||||
}
|
}
|
||||||
@@ -69,40 +118,61 @@ const onAgentSelect = index => {
|
|||||||
v-if="items.length"
|
v-if="items.length"
|
||||||
ref="tagAgentsRef"
|
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"
|
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
|
<li
|
||||||
v-for="(agent, index) in items"
|
v-for="item in items"
|
||||||
:id="`mention-item-${index}`"
|
:id="
|
||||||
:key="agent.id"
|
item.type === 'header'
|
||||||
|
? undefined
|
||||||
|
: `mention-item-${getSelectableIndex(item)}`
|
||||||
|
"
|
||||||
|
:key="`${item.type}-${item.id}`"
|
||||||
|
>
|
||||||
|
<!-- Section Header -->
|
||||||
|
<div
|
||||||
|
v-if="item.type === 'header'"
|
||||||
|
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
<!-- Selectable Item -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-black2': index === selectedIndex,
|
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
|
||||||
'last:mb-0': items.length <= 4,
|
|
||||||
}"
|
}"
|
||||||
class="flex items-center px-2 py-1 rounded-md"
|
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
|
||||||
@click="onAgentSelect(index)"
|
role="option"
|
||||||
@mouseover="onHover(index)"
|
@click="onAgentSelect(getSelectableIndex(item))"
|
||||||
|
@mouseover="onHover(getSelectableIndex(item))"
|
||||||
>
|
>
|
||||||
<div class="ltr:mr-2 rtl:ml-2">
|
<div class="ltr:mr-2 rtl:ml-2">
|
||||||
<Avatar :src="agent.thumbnail" :name="agent.name" rounded-full />
|
<Avatar
|
||||||
|
:src="item.thumbnail"
|
||||||
|
:name="item.displayName"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
|
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
|
||||||
>
|
>
|
||||||
<h5
|
<h5
|
||||||
class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
|
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
|
||||||
:class="{
|
:class="{
|
||||||
'text-n-slate-12': index === selectedIndex,
|
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ agent.name }}
|
{{ item.displayName }}
|
||||||
</h5>
|
</h5>
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||||
:class="{
|
:class="{
|
||||||
'text-n-slate-11': index === selectedIndex,
|
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ agent.email }}
|
{{ item.displayInfo }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -301,11 +301,18 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
|||||||
const createNode = (editorView, nodeType, content) => {
|
const createNode = (editorView, nodeType, content) => {
|
||||||
const { state } = editorView;
|
const { state } = editorView;
|
||||||
switch (nodeType) {
|
switch (nodeType) {
|
||||||
case 'mention':
|
case 'mention': {
|
||||||
return state.schema.nodes.mention.create({
|
const mentionType = content.type || 'user';
|
||||||
|
const displayName = content.displayName || content.name;
|
||||||
|
|
||||||
|
const mentionNode = state.schema.nodes.mention.create({
|
||||||
userId: content.id,
|
userId: content.id,
|
||||||
userFullName: content.name,
|
userFullName: displayName,
|
||||||
|
mentionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mentionNode;
|
||||||
|
}
|
||||||
case 'cannedResponse':
|
case 'cannedResponse':
|
||||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||||
case 'variable':
|
case 'variable':
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe('getContentNode', () => {
|
|||||||
{
|
{
|
||||||
userId: content.id,
|
userId: content.id,
|
||||||
userFullName: content.name,
|
userFullName: content.name,
|
||||||
|
mentionType: 'user',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
|
getContentNode,
|
||||||
} from '../editorHelper';
|
} from '../editorHelper';
|
||||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||||
@@ -18,12 +19,28 @@ const schema = new Schema({
|
|||||||
nodes: {
|
nodes: {
|
||||||
doc: { content: 'paragraph+' },
|
doc: { content: 'paragraph+' },
|
||||||
paragraph: {
|
paragraph: {
|
||||||
content: 'text*',
|
content: 'inline*',
|
||||||
|
group: 'block',
|
||||||
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
|
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
|
group: 'inline',
|
||||||
toDOM: node => node.text, // Represents text as its actual string value.
|
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();
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,6 +96,10 @@
|
|||||||
"NEXT_WEEK": "Next week"
|
"NEXT_WEEK": "Next week"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MENTION": {
|
||||||
|
"AGENTS": "Agents",
|
||||||
|
"TEAMS": "Teams"
|
||||||
|
},
|
||||||
"CUSTOM_SNOOZE": {
|
"CUSTOM_SNOOZE": {
|
||||||
"TITLE": "Snooze until",
|
"TITLE": "Snooze until",
|
||||||
"APPLY": "Snooze",
|
"APPLY": "Snooze",
|
||||||
|
|||||||
@@ -19,14 +19,33 @@ class Messages::MentionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mentioned_ids
|
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
|
end
|
||||||
|
|
||||||
def filter_mentioned_ids_by_inbox
|
def filter_mentioned_ids_by_inbox
|
||||||
inbox = message.inbox
|
mentioned_ids & valid_mentionable_user_ids.map(&:to_s)
|
||||||
valid_mentionable_ids = inbox.account.administrators.map(&:id) + inbox.members.map(&:id)
|
|
||||||
# Intersection of ids
|
|
||||||
mentioned_ids & valid_mentionable_ids.uniq.map(&:to_s)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@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",
|
"@chatwoot/utils": "^0.0.47",
|
||||||
"@formkit/core": "^1.6.7",
|
"@formkit/core": "^1.6.7",
|
||||||
"@formkit/vue": "^1.6.7",
|
"@formkit/vue": "^1.6.7",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -20,8 +20,8 @@ importers:
|
|||||||
specifier: 1.2.3
|
specifier: 1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
'@chatwoot/prosemirror-schema':
|
'@chatwoot/prosemirror-schema':
|
||||||
specifier: 1.1.1-next
|
specifier: 1.1.6-next
|
||||||
version: 1.1.1-next
|
version: 1.1.6-next
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.47
|
specifier: ^0.0.47
|
||||||
version: 0.0.47
|
version: 0.0.47
|
||||||
@@ -403,8 +403,8 @@ packages:
|
|||||||
'@chatwoot/ninja-keys@1.2.3':
|
'@chatwoot/ninja-keys@1.2.3':
|
||||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||||
|
|
||||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
'@chatwoot/prosemirror-schema@1.1.6-next':
|
||||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.47':
|
'@chatwoot/utils@0.0.47':
|
||||||
resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
|
resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
|
||||||
@@ -5237,7 +5237,7 @@ snapshots:
|
|||||||
hotkeys-js: 3.8.7
|
hotkeys-js: 3.8.7
|
||||||
lit: 2.2.6
|
lit: 2.2.6
|
||||||
|
|
||||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
'@chatwoot/prosemirror-schema@1.1.6-next':
|
||||||
dependencies:
|
dependencies:
|
||||||
markdown-it-sup: 2.0.0
|
markdown-it-sup: 2.0.0
|
||||||
prosemirror-commands: 1.6.0
|
prosemirror-commands: 1.6.0
|
||||||
|
|||||||
@@ -5,69 +5,503 @@ describe Messages::MentionService do
|
|||||||
let!(:user) { create(:user, account: account) }
|
let!(:user) { create(:user, account: account) }
|
||||||
let!(:first_agent) { create(:user, account: account) }
|
let!(:first_agent) { create(:user, account: account) }
|
||||||
let!(:second_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!(:inbox) { create(:inbox, account: account) }
|
||||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
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 }
|
let(:builder) { double }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
create(:inbox_member, user: first_agent, inbox: inbox)
|
create(:inbox_member, user: first_agent, inbox: inbox)
|
||||||
create(:inbox_member, user: second_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
|
conversation.reload
|
||||||
allow(NotificationBuilder).to receive(:new).and_return(builder)
|
allow(NotificationBuilder).to receive(:new).and_return(builder)
|
||||||
allow(builder).to receive(:perform)
|
allow(builder).to receive(:perform)
|
||||||
|
allow(Conversations::UserMentionJob).to receive(:perform_later)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when message contains mention' 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
|
it 'creates notifications for inbox member who was mentioned' do
|
||||||
message = build(
|
message = build(
|
||||||
:message,
|
:message,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
account: account,
|
account: account,
|
||||||
content: "hi [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
|
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||||
private: true
|
private: true
|
||||||
)
|
)
|
||||||
|
|
||||||
described_class.new(message: message).perform
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
expect(NotificationBuilder).to have_received(:new).with(
|
||||||
|
notification_type: 'conversation_mention',
|
||||||
user: first_agent,
|
user: first_agent,
|
||||||
account: account,
|
account: account,
|
||||||
primary_actor: message.conversation,
|
primary_actor: message.conversation,
|
||||||
secondary_actor: message)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when message contains multiple mentions' do
|
context 'when message contains multiple user mentions' do
|
||||||
let(:message) do
|
let(:message) do
|
||||||
build(
|
build(
|
||||||
:message,
|
:message,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
account: account,
|
account: account,
|
||||||
content: "hey [#{second_agent.name}](mention://user/#{second_agent.id}/#{second_agent.name})/
|
content: "hey (mention://user/#{second_agent.id}/#{second_agent.name}) " \
|
||||||
[#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name}),
|
"and (mention://user/#{first_agent.id}/#{first_agent.name}), please look into this?",
|
||||||
please look in to this?",
|
|
||||||
private: true
|
private: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates notifications for inbox member who was mentioned' do
|
it 'creates notifications for all mentioned inbox members' do
|
||||||
described_class.new(message: message).perform
|
described_class.new(message: message).perform
|
||||||
|
|
||||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
expect(NotificationBuilder).to have_received(:new).with(
|
||||||
|
notification_type: 'conversation_mention',
|
||||||
user: second_agent,
|
user: second_agent,
|
||||||
account: account,
|
account: account,
|
||||||
primary_actor: message.conversation,
|
primary_actor: message.conversation,
|
||||||
secondary_actor: message)
|
secondary_actor: message
|
||||||
expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention',
|
)
|
||||||
|
expect(NotificationBuilder).to have_received(:new).with(
|
||||||
|
notification_type: 'conversation_mention',
|
||||||
user: first_agent,
|
user: first_agent,
|
||||||
account: account,
|
account: account,
|
||||||
primary_actor: message.conversation,
|
primary_actor: message.conversation,
|
||||||
secondary_actor: message)
|
secondary_actor: message
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'add the users to the participants list' do
|
it 'adds all mentioned users to the participants list' do
|
||||||
described_class.new(message: message).perform
|
described_class.new(message: message).perform
|
||||||
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
|
||||||
end
|
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: "hey (mention://team/#{other_team.id}/#{other_team.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
|
||||||
|
|
||||||
|
it 'does not process mentions for users from other accounts' do
|
||||||
|
message = build(
|
||||||
|
:message,
|
||||||
|
conversation: conversation,
|
||||||
|
account: account,
|
||||||
|
content: "hey (mention://user/#{other_user.id}/#{other_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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user