mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +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' | ||||||
|         :class="{ |             ? undefined | ||||||
|           'bg-n-alpha-black2': index === selectedIndex, |             : `mention-item-${getSelectableIndex(item)}` | ||||||
|           'last:mb-0': items.length <= 4, |         " | ||||||
|         }" |         :key="`${item.type}-${item.id}`" | ||||||
|         class="flex items-center px-2 py-1 rounded-md" |  | ||||||
|         @click="onAgentSelect(index)" |  | ||||||
|         @mouseover="onHover(index)" |  | ||||||
|       > |       > | ||||||
|         <div class="ltr:mr-2 rtl:ml-2"> |         <!-- Section Header --> | ||||||
|           <Avatar :src="agent.thumbnail" :name="agent.name" rounded-full /> |  | ||||||
|         </div> |  | ||||||
|         <div |         <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 |           {{ item.title }} | ||||||
|             class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis" |         </div> | ||||||
|             :class="{ |         <!-- Selectable Item --> | ||||||
|               'text-n-slate-12': index === selectedIndex, |         <div | ||||||
|             }" |           v-else | ||||||
|           > |           :class="{ | ||||||
|             {{ agent.name }} |             'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex, | ||||||
|           </h5> |           }" | ||||||
|  |           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 |           <div | ||||||
|             class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10" |             class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis" | ||||||
|             :class="{ |  | ||||||
|               'text-n-slate-11': index === selectedIndex, |  | ||||||
|             }" |  | ||||||
|           > |           > | ||||||
|             {{ 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> | ||||||
|         </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 | ||||||
|     it 'creates notifications for inbox member who was mentioned' 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 = 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: "hey (mention://team/#{other_team.id}/#{other_team.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).not_to have_received(:new) | ||||||
|                                                               user: first_agent, |       expect(Conversations::UserMentionJob).not_to have_received(:perform_later) | ||||||
|                                                               account: account, |  | ||||||
|                                                               primary_actor: message.conversation, |  | ||||||
|                                                               secondary_actor: message) |  | ||||||
|     end |     end | ||||||
|   end |  | ||||||
|  |  | ||||||
|   context 'when message contains multiple mentions' do |     it 'does not process mentions for users from other accounts' do | ||||||
|     let(:message) do |       message = 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/#{other_user.id}/#{other_user.name})", | ||||||
|                   [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name}), |  | ||||||
|                    please look in to this?", |  | ||||||
|         private: true |         private: true | ||||||
|       ) |       ) | ||||||
|     end |  | ||||||
|  |  | ||||||
|     it 'creates notifications for inbox member who was mentioned' 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).not_to have_received(:new) | ||||||
|                                                               user: second_agent, |       expect(Conversations::UserMentionJob).not_to have_received(:perform_later) | ||||||
|                                                               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) |  | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth