From 469f0eecd4529cbe387ff3851a1e8f610a2f5504 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 7 Apr 2025 09:43:07 -0600 Subject: [PATCH 01/11] wip --- .../applySimultaneousChatsRestrictions.ts | 47 +++++++------- .../server/hooks/beforeJoinRoom.ts | 23 ++----- .../hooks/checkAgentBeforeTakeInquiry.ts | 28 ++------- .../livechat-enterprise/server/lib/Helper.ts | 63 +++++++++++++++++-- apps/meteor/ee/server/models/raw/Users.ts | 27 ++++++-- .../model-typings/src/models/IUsersModel.ts | 9 ++- packages/models/src/models/Users.ts | 27 +++++++- 7 files changed, 151 insertions(+), 73 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 beca9eb385fbb..8b41614b3a96c 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts @@ -7,6 +7,8 @@ import { callbacks } from '../../../../../lib/callbacks'; callbacks.add( 'livechat.applySimultaneousChatRestrictions', async (_: any, { departmentId }: { departmentId?: string } = {}) => { + const limitFilter: any = []; + if (departmentId) { const departmentLimit = ( @@ -15,39 +17,38 @@ callbacks.add( }) )?.maxNumberSimultaneousChat || 0; if (departmentLimit > 0) { - return { $match: { 'queueInfo.chats': { $gte: Number(departmentLimit) } } }; + limitFilter.push({ 'queueInfo.chatsForDepartment': { $gte: Number(departmentLimit) } }); } } - const maxChatsPerSetting = settings.get('Livechat_maximum_chats_per_agent') as number; - const agentFilter = { + limitFilter.push({ $and: [ - { 'livechat.maxNumberSimultaneousChat': { $gt: 0 } }, + { $expr: { $gt: [{ $convert: { input: '$livechat.maxNumberSimultaneousChat', to: 'double', onError: 0, onNull: 0 } }, 50] } }, + // { 'livechat.maxNumberSimultaneousChat': { $gt: 0 } }, { $expr: { $gte: ['queueInfo.chats', 'livechat.maxNumberSimultaneousChat'] } }, ], - }; - // apply filter only if agent setting is 0 or is disabled - const globalFilter = - maxChatsPerSetting > 0 - ? { - $and: [ + }); + + const maxChatsPerSetting = settings.get('Livechat_maximum_chats_per_agent'); + if (maxChatsPerSetting > 0) { + limitFilter.push({ + $and: [ + { + $or: [ { - $or: [ - { - 'livechat.maxNumberSimultaneousChat': { $exists: false }, - }, - { 'livechat.maxNumberSimultaneousChat': 0 }, - { 'livechat.maxNumberSimultaneousChat': '' }, - { 'livechat.maxNumberSimultaneousChat': null }, - ], + 'livechat.maxNumberSimultaneousChat': { $exists: false }, }, - { 'queueInfo.chats': { $gte: maxChatsPerSetting } }, + { 'livechat.maxNumberSimultaneousChat': 0 }, + { 'livechat.maxNumberSimultaneousChat': '' }, + { 'livechat.maxNumberSimultaneousChat': null }, ], - } - : // dummy filter meaning: don't match anything - { _id: '' }; + }, + { 'queueInfo.chats': { $gte: maxChatsPerSetting } }, + ], + }); + } - return { $match: { $or: [agentFilter, globalFilter] } }; + return { $match: { $or: limitFilter } }; }, callbacks.priority.HIGH, 'livechat-apply-simultaneous-restrictions', diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/beforeJoinRoom.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/beforeJoinRoom.ts index c99af4b0a36d0..b17f0281d5c00 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/beforeJoinRoom.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/beforeJoinRoom.ts @@ -1,11 +1,10 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import type { IUser, IRoom } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; -import { Meteor } from 'meteor/meteor'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; -import { getMaxNumberSimultaneousChat } from '../lib/Helper'; +import { isAgentWithinChatLimits } from '../lib/Helper'; callbacks.add( 'beforeJoinRoom', @@ -17,28 +16,18 @@ callbacks.add( if (!room || !isOmnichannelRoom(room)) { return user; } - const { departmentId } = room; - const maxNumberSimultaneousChat = await getMaxNumberSimultaneousChat({ - agentId: user._id, - departmentId, - }); - - if (maxNumberSimultaneousChat === 0) { - return user; - } - - const userSubs = await Users.getAgentAndAmountOngoingChats(user._id); + const userSubs = await Users.getAgentAndAmountOngoingChats(user._id, departmentId); if (!userSubs) { return user; } + const { queueInfo: { chats = 0, chatsForDepartment = 0 } = {} } = userSubs; - const { queueInfo: { chats = 0 } = {} } = userSubs; - if (maxNumberSimultaneousChat <= chats) { - throw new Meteor.Error('error-max-number-simultaneous-chats-reached', 'Not allowed'); + if (await isAgentWithinChatLimits({ agentId: user._id, departmentId, totalChats: chats, departmentChats: chatsForDepartment })) { + return user; } - return user; + throw new Error('error-max-number-simultaneous-chats-reached'); }, callbacks.priority.MEDIUM, 'livechat-before-join-room', diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts index 9722cf25b2009..76c0f74391031 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts @@ -4,7 +4,7 @@ import { allowAgentSkipQueue } from '../../../../../app/livechat/server/lib/Help import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; -import { getMaxNumberSimultaneousChat } from '../lib/Helper'; +import { getMaxNumberSimultaneousChat, isAgentWithinChatLimits } from '../lib/Helper'; import { cbLogger } from '../lib/logger'; const validateMaxChats = async ({ @@ -48,34 +48,18 @@ const validateMaxChats = async ({ } const { department: departmentId } = inquiry; - - const maxNumberSimultaneousChat = await getMaxNumberSimultaneousChat({ - agentId, - departmentId, - }); - - if (maxNumberSimultaneousChat === 0) { - cbLogger.debug(`Chat can be taken by Agent ${agentId}: max number simultaneous chats on range`); - return agent; - } - - const user = await Users.getAgentAndAmountOngoingChats(agentId); + const user = await Users.getAgentAndAmountOngoingChats(agentId, departmentId); if (!user) { cbLogger.debug({ msg: 'No valid agent found', agentId }); throw new Error('No valid agent found'); } - const { queueInfo: { chats = 0 } = {} } = user; - const maxChats = typeof maxNumberSimultaneousChat === 'number' ? maxNumberSimultaneousChat : parseInt(maxNumberSimultaneousChat, 10); + const { queueInfo: { chats = 0, chatsForDepartment = 0 } = {} } = user; - cbLogger.debug({ msg: 'Validating agent is within max number of chats', agentId, user, maxChats }); - if (maxChats <= chats) { - await callbacks.run('livechat.onMaxNumberSimultaneousChatsReached', inquiry); - throw new Error('error-max-number-simultaneous-chats-reached'); + if (await isAgentWithinChatLimits({ agentId, departmentId, totalChats: chats, departmentChats: chatsForDepartment })) { + return user; } - - cbLogger.debug(`Agent ${agentId} can take inquiry ${inquiry._id}`); - return agent; + throw new Error('error-max-number-simultaneous-chats-reached'); }; callbacks.add('livechat.checkAgentBeforeTakeInquiry', validateMaxChats, callbacks.priority.MEDIUM, 'livechat-before-take-inquiry'); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts index ec2c9d40d689f..92bd844fa94f4 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts @@ -33,20 +33,75 @@ type QueueInfo = { numberMostRecentChats: number; }; +export const isAgentWithinChatLimits = async ({ + agentId, + departmentId, + totalChats, + departmentChats, +}: { + agentId: string; + departmentId?: string; + totalChats: number; + departmentChats: number; +}) => { + let agentLimit = 0; + let globalLimit = 0; + + const user = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_info')); + const { livechat: { maxNumberSimultaneousChat = 0 } = {} } = user || {}; + const numberedLimit = Number(maxNumberSimultaneousChat); + if (numberedLimit > 0) { + agentLimit = numberedLimit; + } else { + const settingLimit = settings.get('Livechat_maximum_chats_per_agent'); + if (settingLimit > 0) { + globalLimit = settingLimit; + } + } + + if (departmentId) { + const department = await LivechatDepartmentRaw.findOneById(departmentId); + const { maxNumberSimultaneousChat = 0 } = department || { maxNumberSimultaneousChat: 0 }; + const departmentLimit = Number(maxNumberSimultaneousChat); + if (departmentLimit > 0) { + if (agentLimit) { + return departmentLimit > departmentChats && agentLimit > totalChats; + } + if (globalLimit) { + return departmentLimit > departmentChats && globalLimit > totalChats; + } + return departmentLimit > departmentChats; + } + } else { + if (agentLimit) { + return agentLimit > totalChats; + } + + if (globalLimit) { + return globalLimit > totalChats; + } + } + + return true; +}; + export const getMaxNumberSimultaneousChat = async ({ agentId, departmentId }: { agentId?: string; departmentId?: string }) => { + let departmentLimit = 0; if (departmentId) { const department = await LivechatDepartmentRaw.findOneById(departmentId); const { maxNumberSimultaneousChat = 0 } = department || { maxNumberSimultaneousChat: 0 }; - if (maxNumberSimultaneousChat > 0) { - return Number(maxNumberSimultaneousChat); + const numberedMaxLimit = Number(maxNumberSimultaneousChat); + if (numberedMaxLimit > 0) { + departmentLimit = numberedMaxLimit; } } if (agentId) { const user = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_info')); const { livechat: { maxNumberSimultaneousChat = 0 } = {} } = user || {}; - if (maxNumberSimultaneousChat > 0) { - return Number(maxNumberSimultaneousChat); + const numberedLimit = Number(maxNumberSimultaneousChat); + if (numberedLimit > 0) { + return Math.min(numberedLimit, departmentLimit); } } diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 9f2ae33019ddf..8f78f993bec37 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -24,7 +24,6 @@ export class UsersEE extends UsersRaw { super(db, trash); } - // @ts-expect-error - typings are good, but JS is not helping getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId @@ -32,13 +31,13 @@ export class UsersEE extends UsersRaw { { $lookup: { from: 'rocketchat_livechat_department_agents', - let: { departmentId: '$departmentId', agentId: '$agentId' }, + let: { userId: '$_id' }, pipeline: [ { - $match: { $expr: { $eq: ['$$agentId', '$_id'] } }, + $match: { $expr: { $eq: ['$$userId', '$agentId'] } }, }, { - $match: { $expr: { $eq: ['$$departmentId', departmentId] } }, + $match: { $expr: { $eq: ['$departmentId', departmentId] } }, }, ], as: 'department', @@ -76,6 +75,26 @@ export class UsersEE extends UsersRaw { 'username': 1, 'lastAssignTime': 1, 'lastRoutingTime': 1, + ...(departmentId + ? { + 'queueInfo.chatsForDepartment': { + $size: { + $filter: { + input: '$subs', + as: 'sub', + cond: { + $and: [ + { $eq: ['$$sub.t', 'l'] }, + { $eq: ['$$sub.open', true] }, + { $ne: ['$$sub.onHold', true] }, + { $eq: ['$$sub.department', departmentId] }, + ], + }, + }, + }, + }, + } + : {}), 'queueInfo.chats': { $size: { $filter: { diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index e5b3c53845393..c16b0c589ffa1 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -117,7 +117,14 @@ export interface IUsersModel extends IBaseModel { ): Promise; getAgentAndAmountOngoingChats( userId: IUser['_id'], - ): Promise<{ agentId: string; username?: string; lastAssignTime?: Date; lastRoutingTime?: Date; queueInfo: { chats: number } }>; + departmentId?: string, + ): Promise<{ + agentId: string; + username?: string; + lastAssignTime?: Date; + lastRoutingTime?: Date; + queueInfo: { chats: number; chatsForDepartment?: number }; + }>; findAllResumeTokensByUserId(userId: IUser['_id']): Promise<{ tokens: IMeteorLoginToken[] }[]>; diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 0c86e727a187f..a0d04282ef4ea 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -678,12 +678,15 @@ export class UsersRaw extends BaseRaw> implements IU return this.updateOne(query, update); } - async getAgentAndAmountOngoingChats(userId: IUser['_id']): Promise<{ + async getAgentAndAmountOngoingChats( + userId: IUser['_id'], + departmentId?: string, + ): Promise<{ agentId: string; username?: string; lastAssignTime?: Date; lastRoutingTime?: Date; - queueInfo: { chats: number }; + queueInfo: { chats: number; chatsForDepartment?: number }; }> { const aggregate = [ { @@ -718,6 +721,26 @@ export class UsersRaw extends BaseRaw> implements IU }, }, }, + ...(departmentId + ? { + 'queueInfo.chatsForDepartment': { + $size: { + $filter: { + input: '$subs', + as: 'sub', + cond: { + $and: [ + { $eq: ['$$sub.t', 'l'] }, + { $eq: ['$$sub.open', true] }, + { $ne: ['$$sub.onHold', true] }, + { $eq: ['$$sub.department', departmentId] }, + ], + }, + }, + }, + }, + } + : {}), }, }, { $sort: { 'queueInfo.chats': 1, 'lastAssignTime': 1, 'lastRoutingTime': 1, 'username': 1 } }, From 206d9aba58c9629944926ef2c2fb694cc962dfea Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 7 Apr 2025 15:16:19 -0600 Subject: [PATCH 02/11] industry standard --- .../server/lib/routing/AutoSelection.ts | 1 + .../applySimultaneousChatsRestrictions.ts | 23 +- .../hooks/checkAgentBeforeTakeInquiry.ts | 2 +- .../livechat-enterprise/server/lib/Helper.ts | 128 ++++++--- apps/meteor/ee/server/models/raw/Users.ts | 41 +-- apps/meteor/tests/data/livechat/users.ts | 22 +- .../tests/end-to-end/api/livechat/07-queue.ts | 243 +++++++++++++++++- 7 files changed, 364 insertions(+), 96 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts index ee3385879cc4c..6b406bb511933 100644 --- a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts +++ b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts @@ -26,6 +26,7 @@ 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 8b41614b3a96c..c0f07b566444d 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applySimultaneousChatsRestrictions.ts @@ -1,5 +1,6 @@ import type { ILivechatDepartment } from '@rocket.chat/core-typings'; import { LivechatDepartment } from '@rocket.chat/models'; +import type { Document } from 'mongodb'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; @@ -7,7 +8,7 @@ import { callbacks } from '../../../../../lib/callbacks'; callbacks.add( 'livechat.applySimultaneousChatRestrictions', async (_: any, { departmentId }: { departmentId?: string } = {}) => { - const limitFilter: any = []; + const limitFilter: Document[] = []; if (departmentId) { const departmentLimit = @@ -22,29 +23,13 @@ callbacks.add( } limitFilter.push({ - $and: [ - { $expr: { $gt: [{ $convert: { input: '$livechat.maxNumberSimultaneousChat', to: 'double', onError: 0, onNull: 0 } }, 50] } }, - // { 'livechat.maxNumberSimultaneousChat': { $gt: 0 } }, - { $expr: { $gte: ['queueInfo.chats', 'livechat.maxNumberSimultaneousChat'] } }, - ], + $and: [{ maxChatsForAgent: { $gt: 0 } }, { $expr: { $gte: ['$queueInfo.chats', '$maxChatsForAgent'] } }], }); const maxChatsPerSetting = settings.get('Livechat_maximum_chats_per_agent'); if (maxChatsPerSetting > 0) { limitFilter.push({ - $and: [ - { - $or: [ - { - 'livechat.maxNumberSimultaneousChat': { $exists: false }, - }, - { 'livechat.maxNumberSimultaneousChat': 0 }, - { 'livechat.maxNumberSimultaneousChat': '' }, - { 'livechat.maxNumberSimultaneousChat': null }, - ], - }, - { 'queueInfo.chats': { $gte: maxChatsPerSetting } }, - ], + $and: [{ maxChatsForAgent: { $eq: 0 } }, { 'queueInfo.chats': { $gte: maxChatsPerSetting } }], }); } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts index 76c0f74391031..778ac97f0c70f 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts @@ -4,7 +4,7 @@ import { allowAgentSkipQueue } from '../../../../../app/livechat/server/lib/Help import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; -import { getMaxNumberSimultaneousChat, isAgentWithinChatLimits } from '../lib/Helper'; +import { isAgentWithinChatLimits } from '../lib/Helper'; import { cbLogger } from '../lib/logger'; const validateMaxChats = async ({ diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts index 92bd844fa94f4..0fab57ac1ed3e 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts @@ -1,5 +1,10 @@ import { api } from '@rocket.chat/core-services'; -import type { IOmnichannelRoom, IOmnichannelServiceLevelAgreements, InquiryWithAgentInfo } from '@rocket.chat/core-typings'; +import type { + ILivechatDepartment, + IOmnichannelRoom, + IOmnichannelServiceLevelAgreements, + InquiryWithAgentInfo, +} from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { Rooms as RoomRaw, @@ -43,16 +48,21 @@ export const isAgentWithinChatLimits = async ({ departmentId?: string; totalChats: number; departmentChats: number; -}) => { +}): Promise => { let agentLimit = 0; let globalLimit = 0; const user = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_info')); - const { livechat: { maxNumberSimultaneousChat = 0 } = {} } = user || {}; - const numberedLimit = Number(maxNumberSimultaneousChat); - if (numberedLimit > 0) { - agentLimit = numberedLimit; - } else { + const rawAgentLimit = user?.livechat?.maxNumberSimultaneousChat; + + if (rawAgentLimit !== undefined && rawAgentLimit !== null) { + const numericAgentLimit = Number(rawAgentLimit); + if (numericAgentLimit > 0) { + agentLimit = numericAgentLimit; + } + } + + if (agentLimit === 0) { const settingLimit = settings.get('Livechat_maximum_chats_per_agent'); if (settingLimit > 0) { globalLimit = settingLimit; @@ -60,52 +70,86 @@ export const isAgentWithinChatLimits = async ({ } if (departmentId) { - const department = await LivechatDepartmentRaw.findOneById(departmentId); - const { maxNumberSimultaneousChat = 0 } = department || { maxNumberSimultaneousChat: 0 }; - const departmentLimit = Number(maxNumberSimultaneousChat); + const department = await LivechatDepartmentRaw.findOneById>(departmentId, { + projection: { maxNumberSimultaneousChat: 1 }, + }); + let departmentLimit = 0; + + if (department?.maxNumberSimultaneousChat !== undefined && department.maxNumberSimultaneousChat !== null) { + const numericDeptLimit = Number(department.maxNumberSimultaneousChat); + if (numericDeptLimit > 0) { + departmentLimit = numericDeptLimit; + } + } + if (departmentLimit > 0) { - if (agentLimit) { - return departmentLimit > departmentChats && agentLimit > totalChats; + if (agentLimit > 0) { + logger.debug({ + msg: 'Applying chat limits', + departmentId, + agentId, + limits: ['department', 'agent'], + totalChats, + departmentChats, + agentLimit, + departmentLimit, + }); + return departmentChats < departmentLimit && totalChats < agentLimit; } - if (globalLimit) { - return departmentLimit > departmentChats && globalLimit > totalChats; + + if (globalLimit > 0) { + logger.debug({ + msg: 'Applying chat limits', + departmentId, + agentId, + limits: ['department', 'global'], + totalChats, + departmentChats, + globalLimit, + departmentLimit, + }); + return departmentChats < departmentLimit && totalChats < globalLimit; } - return departmentLimit > departmentChats; - } - } else { - if (agentLimit) { - return agentLimit > totalChats; - } - if (globalLimit) { - return globalLimit > totalChats; + logger.debug({ + msg: 'Applying chat limits', + departmentId, + agentId, + limits: ['department'], + totalChats, + departmentChats, + departmentLimit, + }); + return departmentChats < departmentLimit; } } - return true; -}; - -export const getMaxNumberSimultaneousChat = async ({ agentId, departmentId }: { agentId?: string; departmentId?: string }) => { - let departmentLimit = 0; - if (departmentId) { - const department = await LivechatDepartmentRaw.findOneById(departmentId); - const { maxNumberSimultaneousChat = 0 } = department || { maxNumberSimultaneousChat: 0 }; - const numberedMaxLimit = Number(maxNumberSimultaneousChat); - if (numberedMaxLimit > 0) { - departmentLimit = numberedMaxLimit; - } + if (agentLimit > 0) { + logger.debug({ + msg: 'Applying chat limits', + departmentId, + agentId, + limits: ['agent'], + totalChats, + agentLimit, + }); + return totalChats < agentLimit; } - if (agentId) { - const user = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_info')); - const { livechat: { maxNumberSimultaneousChat = 0 } = {} } = user || {}; - const numberedLimit = Number(maxNumberSimultaneousChat); - if (numberedLimit > 0) { - return Math.min(numberedLimit, departmentLimit); - } + if (globalLimit > 0) { + logger.debug({ + msg: 'Applying chat limits', + departmentId, + agentId, + limits: ['global'], + totalChats, + globalLimit, + }); + return totalChats < globalLimit; } - return settings.get('Livechat_maximum_chats_per_agent'); + logger.debug({ msg: 'No applicable limit found for user', agentId }); + return true; }; const getWaitingQueueMessage = async (departmentId?: string) => { diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 8f78f993bec37..6859dc0dbce77 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -24,6 +24,7 @@ export class UsersEE extends UsersRaw { super(db, trash); } + // @ts-expect-error - typings are good getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId @@ -34,10 +35,11 @@ export class UsersEE extends UsersRaw { let: { userId: '$_id' }, pipeline: [ { - $match: { $expr: { $eq: ['$$userId', '$agentId'] } }, - }, - { - $match: { $expr: { $eq: ['$departmentId', departmentId] } }, + $match: { + $expr: { + $and: [{ $eq: ['$$userId', '$agentId'] }, { $eq: ['$departmentId', departmentId] }], + }, + }, }, ], as: 'department', @@ -65,16 +67,15 @@ export class UsersEE extends UsersRaw { from: 'rocketchat_subscription', localField: '_id', foreignField: 'u._id', + pipeline: [{ $match: { $and: [{ t: 'l' }, { open: true }, { onHold: { $ne: true } }] } }], as: 'subs', }, }, { $project: { 'agentId': '$_id', - 'livechat.maxNumberSimultaneousChat': 1, + 'maxChatsForAgent': { $convert: { input: '$livechat.maxNumberSimultaneousChat', to: 'double', onError: 0, onNull: 0 } }, 'username': 1, - 'lastAssignTime': 1, - 'lastRoutingTime': 1, ...(departmentId ? { 'queueInfo.chatsForDepartment': { @@ -83,12 +84,7 @@ export class UsersEE extends UsersRaw { input: '$subs', as: 'sub', cond: { - $and: [ - { $eq: ['$$sub.t', 'l'] }, - { $eq: ['$$sub.open', true] }, - { $ne: ['$$sub.onHold', true] }, - { $eq: ['$$sub.department', departmentId] }, - ], + $and: [{ $eq: ['$$sub.department', departmentId] }], }, }, }, @@ -96,27 +92,12 @@ export class UsersEE extends UsersRaw { } : {}), 'queueInfo.chats': { - $size: { - $filter: { - input: '$subs', - as: 'sub', - cond: { - $and: [{ $eq: ['$$sub.t', 'l'] }, { $eq: ['$$sub.open', true] }, { $ne: ['$$sub.onHold', true] }], - }, - }, - }, + $size: '$subs', }, }, }, ...(customFilter ? [customFilter] : []), - { - $sort: { - 'queueInfo.chats': 1, - 'lastAssignTime': 1, - 'lastRoutingTime': 1, - 'username': 1, - }, - }, + { $project: { username: 1 } }, ], { allowDiskUse: true, readPreference: readSecondaryPreferred() }, ) diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 9d0bfd04f170f..b4298ff962c80 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import type { Credentials } from '@rocket.chat/api-client'; import type { ILivechatAgent, IUser } from '@rocket.chat/core-typings'; -import { api, credentials, request } from '../api-data'; +import { api, credentials, request, methodCall } from '../api-data'; import { password } from '../user'; import { createUser, login } from '../users.helper'; import { createAgent, makeAgentAvailable, makeAgentUnavailable } from './rooms'; @@ -76,3 +76,23 @@ export const createAnOfflineAgent = async (): Promise<{ user: agent, }; }; + +export const updateLivechatSettingsForUser = async ( + agentId: string, + livechatSettings: Record, + agentDepartments: string[] = [], +): Promise => { + await request + .post(methodCall('livechat:saveAgentInfo')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'livechat:saveAgentInfo', + params: [agentId, livechatSettings, agentDepartments], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200); +}; 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 10b4df54667ce..745ac649690fc 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 @@ -1,6 +1,7 @@ /* eslint-env mocha */ import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; import type { ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; @@ -8,10 +9,22 @@ import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; import { createDepartmentWithAnOnlineAgent, deleteDepartment, addOrRemoveAgentFromDepartment } from '../../../data/livechat/department'; -import { createVisitor, createLivechatRoom, closeOmnichannelRoom, deleteVisitor } from '../../../data/livechat/rooms'; -import { createAnOnlineAgent } from '../../../data/livechat/users'; +import { + createVisitor, + createLivechatRoom, + closeOmnichannelRoom, + deleteVisitor, + createAgent, + createDepartment, + getLivechatRoomInfo, + makeAgentAvailable, + updateDepartment, +} from '../../../data/livechat/rooms'; +import { createAnOnlineAgent, updateLivechatSettingsForUser } from '../../../data/livechat/users'; +import { sleep } from '../../../data/livechat/utils'; import { updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; -import { deleteUser } from '../../../data/users.helper'; +import { password } from '../../../data/user'; +import { createUser, deleteUser, login } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; const cleanupRooms = async () => { @@ -372,3 +385,227 @@ describe('LIVECHAT - Queue', () => { }); }); }); + +(IS_EE ? describe : describe.skip)('Livechat - Chat limits', () => { + let testUser: { user: IUser; credentials: Credentials }; + let testDepartment: ILivechatDepartment; + let testDepartment2: ILivechatDepartment; + + before((done) => getCredentials(done)); + + before(async () => + Promise.all([ + updateSetting('Livechat_enabled', true), + updateSetting('Livechat_Routing_Method', 'Auto_Selection'), + updateSetting('Omnichannel_enable_department_removal', true), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', true), + ]), + ); + + before(async () => { + const user = await createUser(); + await createAgent(user.username); + const credentials3 = await login(user.username, password); + await makeAgentAvailable(credentials3); + + testUser = { + user, + credentials: credentials3, + }; + }); + + 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, + }); + await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [testDepartment._id, testDepartment2._id]); + }); + + after(async () => + Promise.all([ + deleteUser(testUser.user), + updateSetting('Omnichannel_enable_department_removal', false), + updateEESetting('Livechat_maximum_chats_per_agent', 0), + updateEESetting('Livechat_waiting_queue', false), + deleteDepartment(testDepartment._id), + deleteDepartment(testDepartment2._id), + ]), + ); + + 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); + }); +}); From 76236819e0422b3b6ed39595763e799b9c207e3c Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 7 Apr 2025 15:20:21 -0600 Subject: [PATCH 03/11] Create four-clocks-collect.md --- .changeset/four-clocks-collect.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/four-clocks-collect.md diff --git a/.changeset/four-clocks-collect.md b/.changeset/four-clocks-collect.md new file mode 100644 index 0000000000000..4b6b1e08e8b66 --- /dev/null +++ b/.changeset/four-clocks-collect.md @@ -0,0 +1,13 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/models": patch +--- + +Fixes the behavior of "Maximum number of simultaneous chats" settings, making them more predictable. Previously, we applied a single limit per operation, being the order: `Department > Agent > Global`. This caused the department limit to take prescedence over agent's specific limit, causing some unwanted side effects. + +The new way of applying the filter is as follows: +- An agent can accept chats from multiple departments, respecting each department’s limit individually. +- The total number of active chats (across all departments) must not exceed the configured Agent-Level or Global limit. +- If neither the Agent-Level nor Global Limit is set, only department-specific limits apply. +- If no limits are set at any level, there is no restriction on the number of chats an agent can handle. From cff63deceda26d8046d5d4c52efaaf518f3c870e Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 8 Apr 2025 09:25:42 -0600 Subject: [PATCH 04/11] Every day we stray further from god --- apps/meteor/tests/end-to-end/api/livechat/07-queue.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 745ac649690fc..3a07b5ec28b82 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 @@ -425,16 +425,16 @@ describe('LIVECHAT - Queue', () => { await updateLivechatSettingsForUser(testUser.user._id, { maxNumberSimultaneousChat: 1 }, [testDepartment._id, testDepartment2._id]); }); - after(async () => - Promise.all([ + after(async () => { + await Promise.all([ deleteUser(testUser.user), - updateSetting('Omnichannel_enable_department_removal', false), updateEESetting('Livechat_maximum_chats_per_agent', 0), updateEESetting('Livechat_waiting_queue', false), deleteDepartment(testDepartment._id), deleteDepartment(testDepartment2._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); From 953192af0a76fcb98868e4536fab653de84d7cd8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 9 Apr 2025 14:06:02 -0600 Subject: [PATCH 05/11] unit --- .../livechat-enterprise/lib/Helper.tests.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/Helper.tests.ts diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/Helper.tests.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/Helper.tests.ts new file mode 100644 index 0000000000000..a9c99df7f7cee --- /dev/null +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/lib/Helper.tests.ts @@ -0,0 +1,111 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingGetMock = sinon.stub(); +const usersModelMock = { + getAgentInfo: sinon.stub(), +}; + +const departmentsMock = { findOneById: sinon.stub() }; + +const mocks = { + 'meteor/meteor': { Meteor: { startup: sinon.stub() } }, + './QueueInactivityMonitor': { stop: sinon.stub() }, + '../../../../../app/livechat/server/lib/settings': { getInquirySortMechanismSetting: sinon.stub() }, + '../../../../../app/livechat/lib/inquiries': { getOmniChatSortQuery: sinon.stub() }, + '../../../../../app/settings/server': { settings: { get: settingGetMock } }, + '@rocket.chat/models': { Users: usersModelMock, LivechatDepartment: departmentsMock }, +}; + +const { isAgentWithinChatLimits } = proxyquire.noCallThru().load('../../../../../app/livechat-enterprise/server/lib/Helper.ts', mocks); + +describe('isAgentWithinChatLimits', () => { + beforeEach(() => { + usersModelMock.getAgentInfo.reset(); + departmentsMock.findOneById.reset(); + settingGetMock.reset(); + }); + it('should return true if no limit is set', async () => { + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 }); + expect(res).to.be.true; + }); + it('should return true when agent is under the agent limit', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } }); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 }); + expect(res).to.be.true; + }); + it('should honor agent limit over global limit', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 }); + expect(res).to.be.true; + }); + it('should use global limit if agent limit is not set', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: undefined } }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10 }); + expect(res).to.be.false; + }); + it('should consider a user with the same number of chats as the limit as over the limit', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 15 }); + expect(res).to.be.false; + }); + it('should honor both department and agent limit when departmentId is passed', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } }); + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 10, departmentId: 'dept1', departmentChats: 5 }); + expect(res).to.be.true; + }); + it('should return false for a user under their agent limit but above their department limit', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 15 } }); + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 11 }); + expect(res).to.be.false; + }); + it('should return false for a user under their department limit but above their agent limit', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 10 } }); + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 15 }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 10 }); + expect(res).to.be.false; + }); + it('should honor both department and global when agent limit is not set', async () => { + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 15 }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 10 }); + expect(res).to.be.false; + }); + it('should return false for a user under their global limit but above their department limit', async () => { + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 }); + settingGetMock.returns(20); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 11 }); + expect(res).to.be.false; + }); + it('should return false for a user under their department limit but above the global limit', async () => { + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 }); + settingGetMock.returns(5); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 3 }); + expect(res).to.be.false; + }); + it('should apply only the department limit if the other 2 limits are not set', async () => { + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 10 }); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 3 }); + expect(res).to.be.true; + }); + it('should ignore agent limit if its not a valid number (or cast to number)', async () => { + usersModelMock.getAgentInfo.resolves({ livechat: { maxNumberSimultaneousChat: 'invalid' } }); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11 }); + expect(res).to.be.true; + }); + it('should ignore the department limit if it is not a valid number (or cast to number)', async () => { + departmentsMock.findOneById.resolves({ maxNumberSimultaneousChat: 'invalid' }); + const res = await isAgentWithinChatLimits({ agentId: 'kevs', totalChats: 11, departmentId: 'dept1', departmentChats: 11 }); + expect(res).to.be.true; + }); +}); From 886a624a6250a2d3fb6bb789752db8fd001673f0 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 13:22:46 -0600 Subject: [PATCH 06/11] update agentmetadata --- apps/meteor/ee/server/models/raw/Users.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 6859dc0dbce77..770cbbcd480da 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -5,12 +5,7 @@ import type { Db, Collection } from 'mongodb'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; type AgentMetadata = { - 'agentId'?: string; - 'username'?: string; - 'lastAssignTime'?: Date; - 'lastRoutingTime'?: Date; - 'queueInfo.chats'?: number; - [x: string]: any; + username?: string; }; declare module '@rocket.chat/model-typings' { @@ -52,7 +47,7 @@ export class UsersEE extends UsersRaw { : []; return this.col - .aggregate( + .aggregate( [ { $match: { From c84c89c1f82c2abb80c147dff2143ce8f1e48bab Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 13:28:45 -0600 Subject: [PATCH 07/11] update agentmetadata --- apps/meteor/ee/server/models/raw/Users.ts | 1 - .../model-typings/src/models/IUsersModel.ts | 14 +---------- packages/models/src/models/Users.ts | 23 ++----------------- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 770cbbcd480da..ad7ffb5e08eb9 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -19,7 +19,6 @@ export class UsersEE extends UsersRaw { super(db, trash); } - // @ts-expect-error - typings are good getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index c16b0c589ffa1..f9089d90132e2 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -251,19 +251,7 @@ export interface IUsersModel extends IBaseModel { isLivechatEnabledWhenAgentIdle?: boolean, ): FindCursor; countOnlineUserFromList(userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean): Promise; - getUnavailableAgents( - departmentId?: string, - extraQuery?: Document, - ): Promise< - { - agentId: string; - username: string; - lastAssignTime: string; - lastRoutingTime: string; - livechat: { maxNumberSimultaneousChat: number }; - queueInfo: { chats: number }; - }[] - >; + getUnavailableAgents(departmentId?: string, extraQuery?: Document): Promise<{ username: string }[]>; findOneOnlineAgentByUserList( userList: string[] | string, options?: FindOptions, diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index a0d04282ef4ea..37e3e92f29035 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -1628,27 +1628,8 @@ export class UsersRaw extends BaseRaw> implements IU return this.findOne(query, options); } - async getUnavailableAgents( - _departmentId?: string | undefined, - _extraQuery?: Document | undefined, - ): Promise< - { - agentId: string; - username: string; - lastAssignTime: string; - lastRoutingTime: string; - livechat: { maxNumberSimultaneousChat: number }; - queueInfo: { chats: number }; - }[] - > { - return [] as { - agentId: string; - username: string; - lastAssignTime: string; - lastRoutingTime: string; - livechat: { maxNumberSimultaneousChat: number }; - queueInfo: { chats: number }; - }[]; + async getUnavailableAgents(departmentId?: string, extraQuery?: Document): Promise<{ username: string }[]> { + return [] as { username: string }[]; } findBotAgents(usernameList?: string | string[]): FindCursor { From 8b883b407f8387cf78dc3cb50631d563267015c1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 13:29:14 -0600 Subject: [PATCH 08/11] ts --- apps/meteor/ee/server/models/raw/Users.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index ad7ffb5e08eb9..770cbbcd480da 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -19,6 +19,7 @@ export class UsersEE extends UsersRaw { super(db, trash); } + // @ts-expect-error - typings are good getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId From 58726c1a568ef6f6779d79d217e73deabc14f6b8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 13:31:29 -0600 Subject: [PATCH 09/11] ts --- apps/meteor/ee/server/models/raw/Users.ts | 1 - packages/models/src/models/Users.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 770cbbcd480da..ad7ffb5e08eb9 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -19,7 +19,6 @@ export class UsersEE extends UsersRaw { super(db, trash); } - // @ts-expect-error - typings are good getUnavailableAgents(departmentId: string, customFilter: { [k: string]: any }[]): Promise { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 37e3e92f29035..4a64eca385960 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -1628,7 +1628,7 @@ 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?: Document): Promise<{ username: string }[]> { return [] as { username: string }[]; } From 82e4ba2d0bf692c88ee326745e379399f7572aae Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 13:38:32 -0600 Subject: [PATCH 10/11] ts --- apps/meteor/ee/server/models/raw/Users.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index ad7ffb5e08eb9..31f71b5349499 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -5,7 +5,7 @@ import type { Db, Collection } from 'mongodb'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; type AgentMetadata = { - username?: string; + username: string; }; declare module '@rocket.chat/model-typings' { From 3f11f01c0d04e328599e3721d2714c4e340800f4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 16 Apr 2025 13:46:03 -0600 Subject: [PATCH 11/11] ts --- packages/models/src/models/Users.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 4a64eca385960..aeadbcf907ffc 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -1629,7 +1629,7 @@ export class UsersRaw extends BaseRaw> implements IU } async getUnavailableAgents(_departmentId?: string, _extraQuery?: Document): Promise<{ username: string }[]> { - return [] as { username: string }[]; + return []; } findBotAgents(usernameList?: string | string[]): FindCursor {