diff --git a/.changeset/dull-horses-matter.md b/.changeset/dull-horses-matter.md new file mode 100644 index 0000000000000..ac723cd93ea2f --- /dev/null +++ b/.changeset/dull-horses-matter.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ddp-client': patch +'@rocket.chat/meteor': patch +--- + +Fixes livechat inquiries not routing to the manager queue when manual routing is enabled. diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index 4ebf49d556844..78769e5a960c6 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -28,6 +28,20 @@ const events = { removed: (inquiry: ILivechatInquiryRecord) => removeInquiry(inquiry), }; +type InquiryEventType = keyof typeof events; +type InquiryEventArgs = { type: InquiryEventType } & Omit; + +const processInquiryEvent = async (args: unknown): Promise => { + if (!args || typeof args !== 'object' || !('type' in args)) { + return; + } + + const { type, ...inquiry } = args as InquiryEventArgs; + if (type in events) { + await events[type](inquiry as ILivechatInquiryRecord); + } +}; + const invalidateRoomQueries = async (rid: string) => { await queryClient.invalidateQueries({ queryKey: ['rooms', { reference: rid, type: 'l' }] }); queryClient.removeQueries({ queryKey: ['rooms', rid] }); @@ -53,11 +67,7 @@ const removeListenerOfDepartment = (departmentId: ILivechatDepartment['_id']) => const appendListenerToDepartment = (departmentId: ILivechatDepartment['_id']) => { departments.add(departmentId); sdk.stream('livechat-inquiry-queue-observer', [`department/${departmentId}`], async (args) => { - if (!('type' in args)) { - return; - } - const { type, ...inquiry } = args; - await events[args.type](inquiry); + await processInquiryEvent(args); }); return () => removeListenerOfDepartment(departmentId); }; @@ -78,15 +88,22 @@ const removeGlobalListener = () => sdk.stop('livechat-inquiry-queue-observer', ' const addGlobalListener = () => { sdk.stream('livechat-inquiry-queue-observer', ['public'], async (args) => { - if (!('type' in args)) { - return; - } - const { type, ...inquiry } = args; - await events[args.type](inquiry); + await processInquiryEvent(args); }); return removeGlobalListener; }; +const removeAgentListener = (userId: IOmnichannelAgent['_id']) => { + sdk.stop('livechat-inquiry-queue-observer', `agent/${userId}`); +}; + +const addAgentListener = (userId: IOmnichannelAgent['_id']) => { + sdk.stream('livechat-inquiry-queue-observer', [`agent/${userId}`], async (args) => { + await processInquiryEvent(args); + }); + return () => removeAgentListener(userId); +}; + const subscribe = async (userId: IOmnichannelAgent['_id']) => { const config = await callWithErrorHandling('livechat:getRoutingConfig'); if (config?.autoAssignAgent) { @@ -95,7 +112,8 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { const agentDepartments = (await getAgentsDepartments(userId)).map((department) => department.departmentId); - // Register to all depts + public queue always to match the inquiry list returned by backend + // Register to agent-specific queue, all depts + public queue to match the inquiry list returned by backend + const cleanAgentListener = addAgentListener(userId); const cleanDepartmentListeners = addListenerForeachDepartment(agentDepartments); const globalCleanup = addGlobalListener(); @@ -108,6 +126,7 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => { return () => { LivechatInquiry.remove({}); removeGlobalListener(); + cleanAgentListener?.(); cleanDepartmentListeners?.(); globalCleanup?.(); departments.clear(); diff --git a/apps/meteor/app/livechat/server/api/lib/inquiries.ts b/apps/meteor/app/livechat/server/api/lib/inquiries.ts index 19cbfc21ede9e..fb923b1e000b2 100644 --- a/apps/meteor/app/livechat/server/api/lib/inquiries.ts +++ b/apps/meteor/app/livechat/server/api/lib/inquiries.ts @@ -56,12 +56,18 @@ export async function findInquiries({ // V in Enum only works for numeric enums ...(status && Object.values(LivechatInquiryStatus).includes(status) && { status }), $or: [ + // Cases where this user is the default agent { - $and: [{ defaultAgent: { $exists: true } }, { 'defaultAgent.agentId': userId }], + 'defaultAgent': { $exists: true }, + 'defaultAgent.agentId': userId, + }, + // Cases with no default agent assigned yet, AND either: + // - belongs to one of user's departments, or + // - has no department (public queue) + { + defaultAgent: { $exists: false }, + $or: [...(department ? [{ department }] : []), { department: { $exists: false } }], }, - { ...(department && { department }) }, - // Add _always_ the "public queue" to returned list of inquiries, even if agent already has departments - { department: { $exists: false } }, ], }; diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 8b29527b9ff0e..376e48747ad58 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -143,6 +143,10 @@ export class QueueManager { return LivechatInquiryStatus.READY; } + if (settings.get('Livechat_Routing_Method') === 'Manual_Selection' && agent) { + return LivechatInquiryStatus.QUEUED; + } + if (!agent) { return LivechatInquiryStatus.QUEUED; } @@ -167,8 +171,12 @@ export class QueueManager { if (inquiry.status === LivechatInquiryStatus.QUEUED) { await callbacks.run('livechat.afterInquiryQueued', inquiry); + await callbacks.run('livechat.chatQueued', room); - void callbacks.run('livechat.chatQueued', room); + if (defaultAgent) { + logger.debug(`Setting default agent for inquiry ${inquiry._id} to ${defaultAgent.username}`); + await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent); + } return this.dispatchInquiryQueued(inquiry, room, defaultAgent); } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts index 09be46592fd60..5a1030925ccf3 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts @@ -25,8 +25,19 @@ const getDefaultAgent = async ({ username, id }: { username?: string; id?: strin } if (id) { - return normalizeDefaultAgent(await Users.findOneOnlineAgentById(id, undefined, { projection: { _id: 1, username: 1 } })); + const agent = await Users.findOneOnlineAgentById(id, undefined, { projection: { _id: 1, username: 1 } }); + if (agent) { + return normalizeDefaultAgent(agent); + } + + const offlineAgent = await Users.findOneAgentById(id, { projection: { _id: 1, username: 1 } }); + if (offlineAgent && settings.get('Livechat_accept_chats_with_no_agents')) { + return normalizeDefaultAgent(offlineAgent); + } + + return undefined; } + return normalizeDefaultAgent(await Users.findOneOnlineAgentByUserList(username || [], { projection: { _id: 1, username: 1 } })); }; diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index f0ca92ce82ea0..d0894913cec15 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -223,49 +223,49 @@ export class ListenersModule { service.onEvent('watch.inquiries', async ({ clientAction, inquiry, diff }): Promise => { const type = minimongoChangeMap[clientAction] as 'added' | 'changed' | 'removed'; - if (clientAction === 'removed') { - notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, { - _id: inquiry._id, - clientAction, - }); - if (inquiry.department) { - return notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, { type, ...inquiry }); + const isOnlyQueueMetadataUpdate = (diff: Record | undefined): boolean => { + if (!diff) { + return false; } - return notifications.streamLivechatQueueData.emitWithoutBroadcast('public', { - type, - ...inquiry, - }); - } - - // Don't do notifications for updating inquiries when the only thing changing is the queue metadata - if ( - clientAction === 'updated' && - diff?.hasOwnProperty('lockedAt') && - diff?.hasOwnProperty('locked') && - diff?.hasOwnProperty('_updatedAt') && - Object.keys(diff).length === 3 - ) { - return; - } + const queueMetadataKeys = ['lockedAt', 'locked', '_updatedAt']; + return Object.keys(diff).length === queueMetadataKeys.length && queueMetadataKeys.every((key) => diff.hasOwnProperty(key)); + }; + // Always notify the specific inquiry channel notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, { - ...inquiry, + _id: inquiry._id, + ...(clientAction !== 'removed' && { ...inquiry }), clientAction, }); - if (!inquiry.department) { - return notifications.streamLivechatQueueData.emitWithoutBroadcast('public', { + // Skip further notifications if it's just a queue metadata update + if (clientAction === 'updated' && isOnlyQueueMetadataUpdate(diff)) { + return; + } + + // Notify the defaultAgent if exists + if (inquiry.defaultAgent?.agentId) { + notifications.streamLivechatQueueData.emitWithoutBroadcast(`agent/${inquiry.defaultAgent.agentId}`, { type, ...inquiry, }); } - notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, { type, ...inquiry }); + // Prioritize department-specific channel over public + if (inquiry.department) { + notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, { + type, + ...inquiry, + }); + } - if (clientAction === 'updated' && !diff?.department) { - notifications.streamLivechatQueueData.emitWithoutBroadcast('public', { type, ...inquiry }); + if (!inquiry.department && !inquiry.defaultAgent?.agentId) { + notifications.streamLivechatQueueData.emitWithoutBroadcast('public', { + type, + ...inquiry, + }); } }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts index ee91c49bfb16e..1311ac01ab092 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-queue-management.spec.ts @@ -118,3 +118,124 @@ test.describe('OC - Livechat - Queue Management', () => { }); }); }); + +test.describe('OC - Contact Manager Routing', () => { + test.skip(!IS_EE, 'Enterprise Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLiveChat: OmnichannelLiveChat; + + // User2 will be the contact manager + let poHomeOmnichannelUser2: HomeOmnichannel; + + const visitorWithManager = createFakeVisitor(); + const contactId = `contact-${Date.now()}`; + + test.beforeAll(async ({ api, browser }) => { + await api.post('/livechat/users/agent', { username: 'user2' }); + await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' }); + await api.post('/omnichannel/contact', { + _id: contactId, + name: visitorWithManager.name, + email: visitorWithManager.email, + contactManager: { + username: 'user2', + }, + }); + + const { page: omniPage } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + + const { page: omniPageUser2 } = await createAuxContext(browser, Users.user2, '/', true); + poHomeOmnichannelUser2 = new HomeOmnichannel(omniPageUser2); + }); + + test.beforeEach(async ({ browser, api }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + poLiveChat = new OmnichannelLiveChat(page, api); + await poLiveChat.page.goto('/livechat'); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }), + api.delete('/livechat/users/agent/user1'), + api.delete('/livechat/users/agent/user2'), + api.delete(`/omnichannel/contact/${contactId}`), + ]); + + await poHomeOmnichannel.page.close(); + await poHomeOmnichannelUser2.page.close(); + }); + + test.afterEach(async () => { + await poLiveChat.closeChat(); + await poLiveChat.page.close(); + }); + + test('should route inquiry only to the contact manager', async () => { + await test.step('visitor with contact manager starts a chat', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: visitorWithManager, + message: 'I need assistance', + isOffline: false, + }); + }); + + await test.step('verify non-manager agent does not see the inquiry', async () => { + const nonManagerQueuedChat = poHomeOmnichannel.sidenav.getQueuedChat(visitorWithManager.name); + await expect(nonManagerQueuedChat).toHaveCount(0); + }); + + await test.step('verify the contact manager agent sees the inquiry', async () => { + const managerQueuedChat = poHomeOmnichannelUser2.sidenav.getQueuedChat(visitorWithManager.name); + await expect(managerQueuedChat).toBeVisible(); + }); + + await test.step('contact manager can take the chat', async () => { + await poHomeOmnichannelUser2.sidenav.getQueuedChat(visitorWithManager.name).click(); + await expect(poHomeOmnichannelUser2.content.btnTakeChat).toBeVisible(); + await poHomeOmnichannelUser2.content.btnTakeChat.click(); + await expect(poHomeOmnichannelUser2.content.lastSystemMessageBody).toHaveText('joined the channel'); + }); + }); + + test('inquiry should persist only in contact manager queue after page refresh', async () => { + const anotherVisitorWithManager = createFakeVisitor(); + + await test.step('visitor with contact manager starts a chat', async () => { + await poLiveChat.openAnyLiveChatAndSendMessage({ + liveChatUser: anotherVisitorWithManager, + message: 'I need help after refresh test', + isOffline: false, + }); + }); + + await test.step('refresh both agent pages', async () => { + await poHomeOmnichannel.page.reload(); + await poHomeOmnichannel.page.waitForLoadState('networkidle'); + + await poHomeOmnichannelUser2.page.reload(); + await poHomeOmnichannelUser2.page.waitForLoadState('networkidle'); + }); + + await test.step('verify non-manager agent still does not see the inquiry after refresh', async () => { + const nonManagerQueuedChat = poHomeOmnichannel.sidenav.getQueuedChat(anotherVisitorWithManager.name); + await expect(nonManagerQueuedChat).toHaveCount(0); + }); + + await test.step('verify the contact manager still sees the inquiry after refresh', async () => { + const managerQueuedChat = poHomeOmnichannelUser2.sidenav.getQueuedChat(anotherVisitorWithManager.name); + await expect(managerQueuedChat).toBeVisible(); + }); + + await test.step('contact manager can take the chat after refresh', async () => { + await poHomeOmnichannelUser2.sidenav.getQueuedChat(anotherVisitorWithManager.name).click(); + await expect(poHomeOmnichannelUser2.content.btnTakeChat).toBeVisible(); + await poHomeOmnichannelUser2.content.btnTakeChat.click(); + await expect(poHomeOmnichannelUser2.content.lastSystemMessageBody).toHaveText('joined the channel'); + }); + }); +}); diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index eae9d6c7d4fe1..5b362ead1386b 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -449,6 +449,14 @@ export interface StreamerEvents { } & ILivechatInquiryRecord, ]; }, + { + key: `agent/${string}`; + args: [ + { + type: 'added' | 'removed' | 'changed'; + } & ILivechatInquiryRecord, + ]; + }, { key: `${string}`; args: [