diff --git a/.changeset/tiny-onions-remember.md b/.changeset/tiny-onions-remember.md new file mode 100644 index 0000000000000..1592c42cb0947 --- /dev/null +++ b/.changeset/tiny-onions-remember.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/model-typings": patch +--- + +Fixed an issue caused by the `Fallback Forward Department` feature. Feature could be configured by admins in a way that mimis a loop, causing a chat to be forwarded "infinitely" between those departments. System will now prevent Self & 1-level deep circular references from being saved, and a new setting is added to control the maximum number of hops that the system will do between fallback departments before considering a transfer failure. diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 1750a65aba9fd..ea0c6e370d9d3 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -561,16 +561,20 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi const { servedBy, chatQueued } = roomTaken; if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { const department = departmentId - ? await LivechatDepartment.findOneById>(departmentId, { - projection: { fallbackForwardDepartment: 1 }, + ? await LivechatDepartment.findOneById>(departmentId, { + projection: { fallbackForwardDepartment: 1, name: 1 }, }) : null; if (!department?.fallbackForwardDepartment?.length) { logger.debug(`Cannot forward room ${room._id}. Chat assigned to agent ${servedBy._id} (Previous was ${oldServedBy._id})`); throw new Error('error-no-agents-online-in-department'); } + + if (!transferData.originalDepartmentName) { + transferData.originalDepartmentName = department.name; + } // if a chat has a fallback department, attempt to redirect chat to there [EE] - const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData })); + const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData, department })); // On CE theres no callback so it will return the room if (typeof transferSuccess !== 'boolean' || !transferSuccess) { logger.debug(`Cannot forward room ${room._id}. Unable to delegate inquiry`); @@ -580,6 +584,23 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi return true; } + // Send just 1 message to the room to inform the user that the chat was transferred + if (transferData.usingFallbackDep) { + const { _id, username } = transferData.transferredBy; + await Message.saveSystemMessage( + 'livechat_transfer_history_fallback', + room._id, + '', + { _id, username }, + { + transferData: { + ...transferData, + prevDepartment: transferData.originalDepartmentName, + }, + }, + ); + } + await LivechatTyped.saveTransferHistory(room, transferData); if (oldServedBy) { // if chat is queued then we don't ignore the new servedBy agent bcs at this diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/onTransferFailure.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/onTransferFailure.ts index f5e88ca813d9d..449b240bbccff 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/onTransferFailure.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/onTransferFailure.ts @@ -1,9 +1,9 @@ -import { Message } from '@rocket.chat/core-services'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; -import type { IRoom, ILivechatVisitor, ILivechatDepartment, TransferData } from '@rocket.chat/core-typings'; +import type { IRoom, ILivechatVisitor, ILivechatDepartment, TransferData, AtLeast } from '@rocket.chat/core-typings'; import { LivechatDepartment } from '@rocket.chat/models'; import { forwardRoomToDepartment } from '../../../../../app/livechat/server/lib/Helper'; +import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; import { cbLogger } from '../lib/logger'; @@ -12,27 +12,17 @@ const onTransferFailure = async ( { guest, transferData, + department, }: { guest: ILivechatVisitor; transferData: TransferData; + department: AtLeast; }, ) => { if (!isOmnichannelRoom(room)) { return false; } - const { departmentId } = transferData; - if (!departmentId) { - return false; - } - - const department = await LivechatDepartment.findOneById>( - departmentId, - { - projection: { _id: 1, name: 1, fallbackForwardDepartment: 1 }, - }, - ); - if (!department?.fallbackForwardDepartment?.length) { return false; } @@ -49,27 +39,47 @@ const onTransferFailure = async ( return false; } + const { hops: currentHops = 1 } = transferData; + const maxHops = settings.get('Omnichannel_max_fallback_forward_depth'); + + if (currentHops > maxHops) { + cbLogger.debug({ + msg: 'Max fallback forward depth reached. Chat wont be transfered', + roomId: room._id, + hops: currentHops, + maxHops, + departmentId: department._id, + }); + return false; + } + const transferDataFallback = { ...transferData, + usingFallbackDep: true, prevDepartment: department.name, departmentId: department.fallbackForwardDepartment, department: fallbackDepartment, + hops: currentHops + 1, }; const forwardSuccess = await forwardRoomToDepartment(room, guest, transferDataFallback); - if (forwardSuccess) { - const { _id, username } = transferData.transferredBy; - await Message.saveSystemMessage( - 'livechat_transfer_history_fallback', - room._id, - '', - { _id, username }, - { transferData: transferDataFallback }, - ); + if (!forwardSuccess) { + cbLogger.debug({ + msg: 'Fallback forward failed', + departmentId: department._id, + roomId: room._id, + fallbackDepartmentId: department.fallbackForwardDepartment, + }); + return false; } - cbLogger.info(`Fallback department ${department.fallbackForwardDepartment} found for department ${department._id}. Chat transfered`); - return forwardSuccess; + cbLogger.info({ + msg: 'Fallback forward success', + departmentId: department._id, + roomId: room._id, + fallbackDepartmentId: department.fallbackForwardDepartment, + }); + return true; }; callbacks.add('livechat:onTransferFailure', onTransferFailure, callbacks.priority.HIGH); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts index d2e9252bc39a6..6d0408dffc910 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts @@ -272,8 +272,15 @@ export const LivechatEnterprise = { ); } - if (fallbackForwardDepartment && !(await LivechatDepartmentRaw.findOneById(fallbackForwardDepartment))) { - throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', { method: 'livechat:saveDepartment' }); + if (fallbackForwardDepartment) { + const fallbackDep = await LivechatDepartmentRaw.findOneById(fallbackForwardDepartment, { + projection: { _id: 1, fallbackForwardDepartment: 1 }, + }); + if (!fallbackDep) { + throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', { + method: 'livechat:saveDepartment', + }); + } } const departmentDB = await LivechatDepartmentRaw.createOrUpdateDepartment(_id, departmentData); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/settings.ts b/apps/meteor/ee/app/livechat-enterprise/server/settings.ts index 2863284d6cff9..4dab6eda75550 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/settings.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/settings.ts @@ -34,6 +34,17 @@ export const createSettings = async (): Promise => { modules: ['livechat-enterprise'], }); + await settingsRegistry.add('Omnichannel_max_fallback_forward_depth', 3, { + type: 'int', + group: 'Omnichannel', + section: 'Routing', + i18nLabel: 'Omnichannel_max_fallback_forward_depth', + enterprise: true, + invalidValue: 0, + modules: ['livechat-enterprise'], + enableQuery: omnichannelEnabledQuery, + }); + await settingsRegistry.add('Livechat_last_chatted_agent_routing', false, { type: 'boolean', group: 'Omnichannel', diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 2c544301de1c3..2619781800f5a 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -129,7 +129,11 @@ type ChainedCallbackSignatures = { 'livechat:afterOnHoldChatResumed': (room: Pick) => Pick; 'livechat:onTransferFailure': ( room: IRoom, - params: { guest: ILivechatVisitor; transferData: TransferData }, + params: { + guest: ILivechatVisitor; + transferData: TransferData; + department: AtLeast; + }, ) => IOmnichannelRoom | Promise; 'livechat.afterForwardChatToAgent': (params: { rid: IRoom['_id']; diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json index 02711e3ee4e5b..450590c1e4a6c 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3894,6 +3894,8 @@ "Omnichannel_Reports_Tags_Empty_Subtitle": "This chart shows the most frequently used tags.", "Omnichannel_Reports_Agents_Empty_Subtitle": "This chart displays which agents receive the highest volume of conversations.", "Omnichannel_Reports_Summary": "Gain insights into your operation and export your metrics.", + "Omnichannel_max_fallback_forward_depth": "Maximum fallback forward departments depth", + "Omnichannel_max_fallback_forward_depth_Description": "Maximum number of hops that a room being transfered will do when the target department has a Fallback Forward Department set up. When limit is reached, chat won't be transferred and process will stop. Depending on your configuration, setting a high number may cause performance issues.", "On": "On", "on-hold-livechat-room": "On Hold Omnichannel Room", "on-hold-livechat-room_description": "Permission to on hold omnichannel room", diff --git a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts index 538ae040fde65..54f8739efee33 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts @@ -259,6 +259,24 @@ import { IS_EE } from '../../../e2e/config/constants'; .expect(400); }); + it('should return an error if fallbackForwardDepartment is referencing a department that does not exist', async () => { + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + fallbackForwardDepartment: 'not a department id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should create a new department', async () => { const { body } = await request .post(api('livechat/department')) diff --git a/packages/core-typings/src/omnichannel/routing.ts b/packages/core-typings/src/omnichannel/routing.ts index 43ca0c08f5d29..af27f221c2b17 100644 --- a/packages/core-typings/src/omnichannel/routing.ts +++ b/packages/core-typings/src/omnichannel/routing.ts @@ -38,6 +38,9 @@ export type TransferData = { clientAction?: boolean; scope?: 'agent' | 'department' | 'queue' | 'autoTransferUnansweredChatsToAgent' | 'autoTransferUnansweredChatsToQueue'; comment?: string; + hops?: number; + usingFallbackDep?: boolean; + originalDepartmentName?: string; }; export type TransferByData = {