From a532a089e51880ce71e0929706a17fbeae8b330e Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 15:27:20 -0600 Subject: [PATCH 1/4] optimize chat limits on EE routing algorithms --- .../server/lib/routing/AutoSelection.ts | 1 - .../applySimultaneousChatsRestrictions.ts | 48 ++-- .../server/lib/routing/LoadBalancing.ts | 4 + .../server/lib/routing/LoadRotation.ts | 4 + .../tests/end-to-end/api/livechat/07-queue.ts | 271 +++++++++++++++++- 5 files changed, 304 insertions(+), 24 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts index 6b406bb511933..ee3385879cc4c 100644 --- a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts +++ b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts @@ -26,7 +26,6 @@ class AutoSelection implements IRoutingMethod { } async getNextAgent(department?: string, ignoreAgentId?: string): Promise { - // TODO: apply this extra query to other routing algorithms const extraQuery = await callbacks.run('livechat.applySimultaneousChatRestrictions', undefined, { ...(department ? { departmentId: department } : {}), }); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts index c0f07b566444d..e0b2e3bc1c1b4 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts @@ -5,35 +5,39 @@ import type { Document } from 'mongodb'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; -callbacks.add( - 'livechat.applySimultaneousChatRestrictions', - async (_: any, { departmentId }: { departmentId?: string } = {}) => { - const limitFilter: Document[] = []; +export async function getChatLimitsQuery(departmentId?: string): Promise { + const limitFilter: Document[] = []; - if (departmentId) { - const departmentLimit = - ( - await LivechatDepartment.findOneById>(departmentId, { - projection: { maxNumberSimultaneousChat: 1 }, - }) - )?.maxNumberSimultaneousChat || 0; - if (departmentLimit > 0) { - limitFilter.push({ 'queueInfo.chatsForDepartment': { $gte: Number(departmentLimit) } }); - } + if (departmentId) { + const departmentLimit = + ( + await LivechatDepartment.findOneById>(departmentId, { + projection: { maxNumberSimultaneousChat: 1 }, + }) + )?.maxNumberSimultaneousChat || 0; + if (departmentLimit > 0) { + limitFilter.push({ 'queueInfo.chatsForDepartment': { $gte: Number(departmentLimit) } }); } + } + + limitFilter.push({ + $and: [{ maxChatsForAgent: { $gt: 0 } }, { $expr: { $gte: ['$queueInfo.chats', '$maxChatsForAgent'] } }], + }); + const maxChatsPerSetting = settings.get('Livechat_maximum_chats_per_agent'); + if (maxChatsPerSetting > 0) { limitFilter.push({ - $and: [{ maxChatsForAgent: { $gt: 0 } }, { $expr: { $gte: ['$queueInfo.chats', '$maxChatsForAgent'] } }], + $and: [{ maxChatsForAgent: { $eq: 0 } }, { 'queueInfo.chats': { $gte: maxChatsPerSetting } }], }); + } - const maxChatsPerSetting = settings.get('Livechat_maximum_chats_per_agent'); - if (maxChatsPerSetting > 0) { - limitFilter.push({ - $and: [{ maxChatsForAgent: { $eq: 0 } }, { 'queueInfo.chats': { $gte: maxChatsPerSetting } }], - }); - } + return { $match: { $or: limitFilter } }; +} - return { $match: { $or: limitFilter } }; +callbacks.add( + 'livechat.applySimultaneousChatRestrictions', + async (_: any, { departmentId }: { departmentId?: string } = {}) => { + return getChatLimitsQuery(departmentId); }, callbacks.priority.HIGH, 'livechat-apply-simultaneous-restrictions', diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts index b909e00b27a54..ded9f383fe30f 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts @@ -3,6 +3,7 @@ import { Users } from '@rocket.chat/models'; import { RoutingManager } from '../../../../../../app/livechat/server/lib/RoutingManager'; import { settings } from '../../../../../../app/settings/server'; import type { IRoutingManagerConfig } from '../../../../../../definition/IRoutingManagerConfig'; +import { getChatLimitsQuery } from '../../hooks/applySimultaneousChatsRestrictions'; /* Load Balancing Queuing method: * @@ -29,10 +30,13 @@ class LoadBalancing { } async getNextAgent(department?: string, ignoreAgentId?: string) { + const extraQuery = await getChatLimitsQuery(department); + const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery); const nextAgent = await Users.getNextLeastBusyAgent( department, ignoreAgentId, settings.get('Livechat_enabled_when_agent_idle'), + unavailableUsers.map((u) => u.username), ); if (!nextAgent) { return; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts index 7c915c65b8812..dda0ac5c98861 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts @@ -4,6 +4,7 @@ import { Users } from '@rocket.chat/models'; import { RoutingManager } from '../../../../../../app/livechat/server/lib/RoutingManager'; import { settings } from '../../../../../../app/settings/server'; import type { IRoutingManagerConfig } from '../../../../../../definition/IRoutingManagerConfig'; +import { getChatLimitsQuery } from '../../hooks/applySimultaneousChatsRestrictions'; /* Load Rotation Queuing method: * Routing method where the agent with the oldest routing time is the next agent to serve incoming chats @@ -28,10 +29,13 @@ class LoadRotation { } public async getNextAgent(department?: string, ignoreAgentId?: string): Promise { + const extraQuery = await getChatLimitsQuery(department); + const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery); const nextAgent = await Users.getLastAvailableAgentRouted( department, ignoreAgentId, settings.get('Livechat_enabled_when_agent_idle'), + unavailableUsers.map((user) => user.username), ); if (!nextAgent?.username) { return; diff --git a/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts b/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts index 3a07b5ec28b82..b9ba85bc7825c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/07-queue.ts @@ -386,7 +386,7 @@ describe('LIVECHAT - Queue', () => { }); }); -(IS_EE ? describe : describe.skip)('Livechat - Chat limits', () => { +(IS_EE ? describe : describe.skip)('Livechat - Chat limits - AutoSelection', () => { let testUser: { user: IUser; credentials: Credentials }; let testDepartment: ILivechatDepartment; let testDepartment2: ILivechatDepartment; @@ -609,3 +609,272 @@ describe('LIVECHAT - Queue', () => { expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); }); + +// Note: didn't add for LoadRotation as everything that changes is how the agent is selected +// but the limits applicable are the same as load balance and autoselection +(IS_EE ? describe : describe.skip)('Livechat - Chat limits - LoadBalance', () => { + let testUser: { user: IUser; credentials: Credentials }; + let testUser2: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + let testDepartment3: ILivechatDepartment; + + before((done) => getCredentials(done)); + + before(async () => + Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Load_Balancing'), + updateSetting('Omnichannel_enable_department_removal', true), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', true), + ]), + ); + + before(async () => { + const user = await createUser(); + const user2 = await createUser(); + await createAgent(user.username); + await createAgent(user2.username); + const credentials3 = await login(user.username, password); + const credentials4 = await login(user2.username, password); + await makeAgentAvailable(credentials3); + await makeAgentAvailable(credentials4); + + testUser = { + user, + credentials: credentials3, + }; + testUser2 = { + user: user2, + credentials: credentials4, + }; + }); + + before(async () => { + testDepartment = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department`, true, { + maxNumberSimultaneousChat: 2, + }); + testDepartment2 = await createDepartment([{ agentId: testUser.user._id }], `${new Date().toISOString()}-department2`, true, { + maxNumberSimultaneousChat: 2, + }); + testDepartment3 = await createDepartment( + [{ agentId: testUser.user._id }, { agentId: testUser2.user._id }], + `${new Date().toISOString()}-department3`, + true, + { + maxNumberSimultaneousChat: 2, + }, + ); + + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [ + testDepartment._id, + testDepartment2._id, + testDepartment3._id, + ]); + await updateLivechatSettingsForUser(testUser2.user._id, { maxNumberSimultaneousChat: 1 }, [testDepartment3._id]); + }); + + after(async () => { + await Promise.all([ + deleteUser(testUser.user), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', false), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + deleteDepartment(testDepartment._id), + deleteDepartment(testDepartment2._id), + deleteDepartment(testDepartment3._id), + ]); + await updateSetting('Omnichannel_enable_department_removal', false); + }); + + it('should allow a user to take a chat on a department since agent limit is set to 1 and department limit is set to 2 (agent has 0 chats)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + let previousChat: string; + it('should not allow a user to take a chat on a department since agent limit is set to 1 and department limit is set to 2 (agent has 1 chat)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow a user to take a chat on a department when agent limit is increased to 2 and department limit is set to 2 (agent has 1 chat)', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 2 }, [testDepartment._id, testDepartment2._id]); + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take chat on department B when agent limit is 2 and already has 2 chats', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow user to take a chat on department B when agent limit is increased to 3 and user has 2 chats on department A', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 3 }, [testDepartment._id, testDepartment2._id]); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should allow a user to take a chat on department B when agent limit is 0 and department B limit is 2 (user has 3 chats)', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 0 }, [testDepartment._id, testDepartment2._id]); + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take a chat on department B when is on the limit (user has 4 chats, 2 chats on department B)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should not allow user to take a chat on department B even if global limit allows it (user has 4 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 6); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.undefined; + }); + it('should allow user to take a chat when department B limit is removed and its below global limit (user has 4 chats)', async () => { + await updateDepartment({ departmentId: testDepartment2._id, opts: { maxNumberSimultaneousChat: 0 }, userCredentials: credentials }); + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should allow user to take a chat on department B (user has 5 chats, global limit is 6, department limit is 0)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take a chat on department B (user has 6 chats, global limit is 6, department limit is 0)', async () => { + const visitor = await createVisitor(testDepartment2._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + it('should allow user to take chat once the global limit is removed (user has 7 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 0); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should not allow user to take chat on department A (as limit for it hasnt changed)', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + }); + + it('should allow user to take a chat on department A when its limit gets removed (no agent, global or department filter are applied)', async () => { + await updateDepartment({ departmentId: testDepartment._id, opts: { maxNumberSimultaneousChat: 0 }, userCredentials: credentials }); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should honor agent limit over global limit when both are set (user has 8 chats)', async () => { + await updateEESetting('Livechat_maximum_chats_per_agent', 100000); + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 4 }, [testDepartment._id, testDepartment2._id]); + + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.undefined; + previousChat = room._id; + }); + + // We already tested this case but this way the queue ends up empty :) + it('should receive the chat after agent limit is removed', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 0 }, [testDepartment._id, testDepartment2._id]); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(previousChat); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); + }); + + it('should route the chat to another agent if limit for agent A is reached and agent B is available', async () => { + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [ + testDepartment._id, + testDepartment2._id, + testDepartment3._id, + ]); + + const visitor = await createVisitor(testDepartment3._id); + const room = await createLivechatRoom(visitor.token); + + await sleep(5000); + const roomInfo = await getLivechatRoomInfo(room._id); + + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); +}); From ee6299f4aa815f01c2f18791a1a4f8c29941fd07 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 15:39:36 -0600 Subject: [PATCH 2/4] Create purple-hairs-hang.md --- .changeset/purple-hairs-hang.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/purple-hairs-hang.md diff --git a/.changeset/purple-hairs-hang.md b/.changeset/purple-hairs-hang.md new file mode 100644 index 0000000000000..bb7afddddc693 --- /dev/null +++ b/.changeset/purple-hairs-hang.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where enterprise routing algorithms could get stuck on selecting the same agent due to chat limits being applied after agent selection, but before agent assignment From 67153dd0364fe66fbde5576b348ce76505fc9f7b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 16:38:53 -0600 Subject: [PATCH 3/4] dum --- .../model-typings/src/models/IUsersModel.ts | 6 +- packages/models/src/models/Users.ts | 101 +++++++++++------- 2 files changed, 69 insertions(+), 38 deletions(-) diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index f9089d90132e2..51260710bcba0 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -100,12 +100,14 @@ export interface IUsersModel extends IBaseModel { department?: string, ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, - ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number; departments?: any[] }>; + ignoreUsernames?: string[], + ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number }>; getLastAvailableAgentRouted( department?: string, ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, - ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; departments?: any[] }>; + ignoreUsernames?: string[], + ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date }>; setLastRoutingTime(userId: IUser['_id']): Promise | null>; diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index aeadbcf907ffc..99deba3e4ba26 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -544,10 +544,40 @@ export class UsersRaw extends BaseRaw> implements IU department?: string, ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, + ignoreUsernames?: string[], ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number; departments?: any[] }> { - const match = queryStatusAgentOnline({ ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }) }, isEnabledWhenAgentIdle); + const match = queryStatusAgentOnline( + { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), ...(ignoreUsernames?.length && { username: { $nin: ignoreUsernames } }) }, + isEnabledWhenAgentIdle, + ); + + const departmentFilter = department + ? [ + { + $lookup: { + from: 'rocketchat_livechat_department_agents', + let: { userId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ['$$userId', '$agentId'] }, { $eq: ['$departmentId', department] }], + }, + }, + }, + ], + as: 'department', + }, + }, + { + $match: { department: { $size: 1 } }, + }, + ] + : []; + const aggregate: Document[] = [ { $match: match }, + ...departmentFilter, { $lookup: { from: 'rocketchat_subscription', @@ -569,35 +599,20 @@ export class UsersRaw extends BaseRaw> implements IU as: 'subs', }, }, - { - $lookup: { - from: 'rocketchat_livechat_department_agents', - localField: '_id', - foreignField: 'agentId', - as: 'departments', - }, - }, { $project: { agentId: '$_id', username: 1, lastRoutingTime: 1, - departments: 1, count: { $size: '$subs' }, }, }, { $sort: { count: 1, lastRoutingTime: 1, username: 1 } }, + { $limit: 1 }, ]; - if (department) { - aggregate.push({ $unwind: '$departments' }); - aggregate.push({ $match: { 'departments.departmentId': department } }); - } - - aggregate.push({ $limit: 1 }); - const [agent] = await this.col - .aggregate<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number; departments?: any[] }>(aggregate) + .aggregate<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number }>(aggregate) .toArray(); if (agent) { await this.setLastRoutingTime(agent.agentId); @@ -610,32 +625,46 @@ export class UsersRaw extends BaseRaw> implements IU department?: string, ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, + ignoreUsernames?: string[], ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; departments?: any[] }> { - const match = queryStatusAgentOnline({ ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }) }, isEnabledWhenAgentIdle); + const match = queryStatusAgentOnline( + { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), ...(ignoreUsernames?.length && { username: { $nin: ignoreUsernames } }) }, + isEnabledWhenAgentIdle, + ); + const departmentFilter = department + ? [ + { + $lookup: { + from: 'rocketchat_livechat_department_agents', + let: { userId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ['$$userId', '$agentId'] }, { $eq: ['$departmentId', department] }], + }, + }, + }, + ], + as: 'department', + }, + }, + { + $match: { department: { $size: 1 } }, + }, + ] + : []; + const aggregate: Document[] = [ { $match: match }, - { - $lookup: { - from: 'rocketchat_livechat_department_agents', - localField: '_id', - foreignField: 'agentId', - as: 'departments', - }, - }, - { $project: { agentId: '$_id', username: 1, lastRoutingTime: 1, departments: 1 } }, + ...departmentFilter, + { $project: { agentId: '$_id', username: 1, lastRoutingTime: 1 } }, { $sort: { lastRoutingTime: 1, username: 1 } }, ]; - if (department) { - aggregate.push({ $unwind: '$departments' }); - aggregate.push({ $match: { 'departments.departmentId': department } }); - } - aggregate.push({ $limit: 1 }); - const [agent] = await this.col - .aggregate<{ agentId: string; username?: string; lastRoutingTime?: Date; departments?: any[] }>(aggregate) - .toArray(); + const [agent] = await this.col.aggregate<{ agentId: string; username?: string; lastRoutingTime?: Date }>(aggregate).toArray(); if (agent) { await this.setLastRoutingTime(agent.agentId); } From 5b8ad6a068ef202b3682e87a9b9cec1b592ff7c2 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 17 Apr 2025 14:20:36 -0300 Subject: [PATCH 4/4] improve function types --- .../applySimultaneousChatsRestrictions.ts | 8 ++++---- apps/meteor/ee/server/models/raw/Users.ts | 20 ++++++++++--------- packages/core-typings/src/ILivechatAgent.ts | 7 +++++++ .../models/ILivechatDepartmentAgentsModel.ts | 4 ++-- .../model-typings/src/models/IUsersModel.ts | 8 ++++++-- .../src/models/LivechatDepartmentAgents.ts | 4 ++-- packages/models/src/models/Users.ts | 8 ++++++-- 7 files changed, 38 insertions(+), 21 deletions(-) diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts index e0b2e3bc1c1b4..2de13028e12e4 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts @@ -1,12 +1,12 @@ -import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, AvailableAgentsAggregation } from '@rocket.chat/core-typings'; import { LivechatDepartment } from '@rocket.chat/models'; -import type { Document } from 'mongodb'; +import type { Filter } from 'mongodb'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; -export async function getChatLimitsQuery(departmentId?: string): Promise { - const limitFilter: Document[] = []; +export async function getChatLimitsQuery(departmentId?: string): Promise> { + const limitFilter: Filter = []; if (departmentId) { const departmentLimit = diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 31f71b5349499..d7324f7f3a08e 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -1,16 +1,15 @@ -import type { RocketChatRecordDeleted, IUser } from '@rocket.chat/core-typings'; +import type { RocketChatRecordDeleted, IUser, AvailableAgentsAggregation } from '@rocket.chat/core-typings'; import { UsersRaw } from '@rocket.chat/models'; -import type { Db, Collection } from 'mongodb'; +import type { Db, Collection, Filter } from 'mongodb'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; -type AgentMetadata = { - username: string; -}; - declare module '@rocket.chat/model-typings' { interface IUsersModel { - getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise; + getUnavailableAgents( + departmentId: string, + customFilter: Filter, + ): Promise[]>; } } @@ -19,7 +18,10 @@ export class UsersEE extends UsersRaw { super(db, trash); } - getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise { + getUnavailableAgents( + departmentId: string, + customFilter: Filter, + ): Promise[]> { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId ? [ @@ -46,7 +48,7 @@ export class UsersEE extends UsersRaw { : []; return this.col - .aggregate( + .aggregate( [ { $match: { diff --git a/packages/core-typings/src/ILivechatAgent.ts b/packages/core-typings/src/ILivechatAgent.ts index f106239400b17..4a67930942b2d 100644 --- a/packages/core-typings/src/ILivechatAgent.ts +++ b/packages/core-typings/src/ILivechatAgent.ts @@ -15,3 +15,10 @@ export interface ILivechatAgent extends IUser { livechatStatusSystemModified?: boolean; openBusinessHours?: string[]; } + +export type AvailableAgentsAggregation = { + agentId: string; + username: string; + maxChatsForAgent: number; + queueInfo: { chats: number; chatsForDepartment?: number }; +}; diff --git a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts index 827b266421b32..5435f1ea4458f 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartmentAgents, IUser } from '@rocket.chat/core-typings'; +import type { AvailableAgentsAggregation, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; import type { DeleteResult, FindCursor, FindOptions, Document, UpdateResult, Filter, AggregationCursor } from 'mongodb'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -57,7 +57,7 @@ export interface ILivechatDepartmentAgentsModel extends IBaseModel, + extraQuery?: Filter, ): Promise | null | undefined>; checkOnlineForDepartment(departmentId: string): Promise; getOnlineForDepartment( diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 51260710bcba0..63039ab40d025 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -1,4 +1,5 @@ import type { + AvailableAgentsAggregation, IUser, IRole, ILivechatAgent, @@ -253,7 +254,10 @@ export interface IUsersModel extends IBaseModel { isLivechatEnabledWhenAgentIdle?: boolean, ): FindCursor; countOnlineUserFromList(userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean): Promise; - getUnavailableAgents(departmentId?: string, extraQuery?: Document): Promise<{ username: string }[]>; + getUnavailableAgents( + departmentId?: string, + extraQuery?: Filter, + ): Promise[]>; findOneOnlineAgentByUserList( userList: string[] | string, options?: FindOptions, @@ -292,7 +296,7 @@ export interface IUsersModel extends IBaseModel { countAgents(): Promise; getNextAgent( ignoreAgentId?: string, - extraQuery?: Filter, + extraQuery?: Filter, enabledWhenAgentIdle?: boolean, ): Promise<{ agentId: string; username?: string } | null>; getNextBotAgent(ignoreAgentId?: string): Promise<{ agentId: string; username?: string } | null>; diff --git a/packages/models/src/models/LivechatDepartmentAgents.ts b/packages/models/src/models/LivechatDepartmentAgents.ts index 064a59704154c..ddb5b7f40e518 100644 --- a/packages/models/src/models/LivechatDepartmentAgents.ts +++ b/packages/models/src/models/LivechatDepartmentAgents.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartmentAgents, RocketChatRecordDeleted, IUser } from '@rocket.chat/core-typings'; +import type { AvailableAgentsAggregation, ILivechatDepartmentAgents, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { FindPaginated, ILivechatDepartmentAgentsModel } from '@rocket.chat/model-typings'; import type { Collection, @@ -177,7 +177,7 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw, + extraQuery?: Filter, ): Promise | null | undefined> { const agents = await this.findByDepartmentId(departmentId).toArray(); diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 99deba3e4ba26..9fa764fd0e9da 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -1,4 +1,5 @@ import type { + AvailableAgentsAggregation, AtLeast, DeepWritable, ILivechatAgent, @@ -1657,7 +1658,10 @@ export class UsersRaw extends BaseRaw> implements IU return this.findOne(query, options); } - async getUnavailableAgents(_departmentId?: string, _extraQuery?: Document): Promise<{ username: string }[]> { + async getUnavailableAgents( + _departmentId?: string, + _extraQuery?: Filter, + ): Promise[]> { return []; } @@ -1939,7 +1943,7 @@ export class UsersRaw extends BaseRaw> implements IU } // 2 - async getNextAgent(ignoreAgentId?: string, extraQuery?: Filter, enabledWhenAgentIdle?: boolean) { + async getNextAgent(ignoreAgentId?: string, extraQuery?: Filter, enabledWhenAgentIdle?: boolean) { // TODO: Create class Agent // fetch all unavailable agents, and exclude them from the selection const unavailableAgents = (await this.getUnavailableAgents(undefined, extraQuery)).map((u) => u.username);