fix: Prevent `Fallback forward departments` feature to go on loop when configured as circular chain (#31328)

pull/31336/head
Kevin Aleman 2 years ago committed by GitHub
parent 7c6198f49f
commit b4b2cd20a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/tiny-onions-remember.md
  2. 27
      apps/meteor/app/livechat/server/lib/Helper.ts
  3. 60
      apps/meteor/ee/app/livechat-enterprise/server/hooks/onTransferFailure.ts
  4. 11
      apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts
  5. 11
      apps/meteor/ee/app/livechat-enterprise/server/settings.ts
  6. 6
      apps/meteor/lib/callbacks.ts
  7. 2
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  8. 18
      apps/meteor/tests/end-to-end/api/livechat/10-departments.ts
  9. 3
      packages/core-typings/src/omnichannel/routing.ts

@ -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.

@ -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<Pick<ILivechatDepartment, '_id' | 'fallbackForwardDepartment'>>(departmentId, {
projection: { fallbackForwardDepartment: 1 },
? await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'fallbackForwardDepartment' | 'name'>>(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

@ -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<ILivechatDepartment, '_id' | 'fallbackForwardDepartment' | 'name'>;
},
) => {
if (!isOmnichannelRoom(room)) {
return false;
}
const { departmentId } = transferData;
if (!departmentId) {
return false;
}
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'name' | '_id' | 'fallbackForwardDepartment'>>(
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<number>('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);

@ -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);

@ -34,6 +34,17 @@ export const createSettings = async (): Promise<void> => {
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',

@ -129,7 +129,11 @@ type ChainedCallbackSignatures = {
'livechat:afterOnHoldChatResumed': (room: Pick<IOmnichannelRoom, '_id'>) => Pick<IOmnichannelRoom, '_id'>;
'livechat:onTransferFailure': (
room: IRoom,
params: { guest: ILivechatVisitor; transferData: TransferData },
params: {
guest: ILivechatVisitor;
transferData: TransferData;
department: AtLeast<ILivechatDepartmentRecord, '_id' | 'fallbackForwardDepartment' | 'name'>;
},
) => IOmnichannelRoom | Promise<boolean>;
'livechat.afterForwardChatToAgent': (params: {
rid: IRoom['_id'];

@ -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",

@ -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'))

@ -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 = {

Loading…
Cancel
Save