feat: allowing forward to offline dep (#31976)

Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
pull/32148/head
Allan RIbeiro 2 years ago committed by GitHub
parent 11d65a546c
commit 4aba7c8a26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/strange-rivers-live.md
  2. 23
      apps/meteor/app/livechat/server/lib/Helper.ts
  3. 8
      apps/meteor/app/livechat/server/lib/RoutingManager.ts
  4. 1
      apps/meteor/app/livechat/server/methods/saveDepartment.ts
  5. 14
      apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx
  6. 1
      apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts
  7. 52
      apps/meteor/tests/data/livechat/department.ts
  8. 20
      apps/meteor/tests/data/livechat/users.ts
  9. 138
      apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts
  10. 13
      apps/meteor/tests/end-to-end/api/livechat/10-departments.ts
  11. 2
      packages/core-typings/src/ILivechatDepartment.ts
  12. 2
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,8 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---
Added support for allowing agents to forward inquiries to departments that may not have any online agents given that `Allow department to receive forwarded inquiries even when there's no available agents` is set to `true` in the department configuration.
This configuration empowers agents to seamlessly direct incoming requests to the designated department, ensuring efficient handling of queries even when departmental resources are not actively online. When an agent becomes available, any pending inquiries will be automatically routed to them if the routing algorithm supports it.

@ -539,10 +539,24 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi
agent = { agentId, username };
}
if (!RoutingManager.getConfig()?.autoAssignAgent || !(await Omnichannel.isWithinMACLimit(room))) {
const department = await LivechatDepartment.findOneById<
Pick<ILivechatDepartment, 'allowReceiveForwardOffline' | 'fallbackForwardDepartment' | 'name'>
>(departmentId, {
projection: {
allowReceiveForwardOffline: 1,
fallbackForwardDepartment: 1,
name: 1,
},
});
if (
!RoutingManager.getConfig()?.autoAssignAgent ||
!(await Omnichannel.isWithinMACLimit(room)) ||
(department?.allowReceiveForwardOffline && !(await LivechatTyped.checkOnlineAgents(departmentId)))
) {
logger.debug(`Room ${room._id} will be on department queue`);
await LivechatTyped.saveTransferHistory(room, transferData);
return RoutingManager.unassignAgent(inquiry, departmentId);
return RoutingManager.unassignAgent(inquiry, departmentId, true);
}
// Fake the department to forward the inquiry - Case the forward process does not success
@ -559,11 +573,6 @@ 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' | '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');

@ -46,7 +46,7 @@ type Routing = {
options?: { clientAction?: boolean; forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any } },
): Promise<(IOmnichannelRoom & { chatQueued?: boolean }) | null | void>;
assignAgent(inquiry: InquiryWithAgentInfo, agent: SelectedAgent): Promise<InquiryWithAgentInfo>;
unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string): Promise<boolean>;
unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string, shouldQueue?: boolean): Promise<boolean>;
takeInquiry(
inquiry: Omit<
ILivechatInquiryRecord,
@ -158,7 +158,7 @@ export const RoutingManager: Routing = {
return inquiry;
},
async unassignAgent(inquiry, departmentId) {
async unassignAgent(inquiry, departmentId, shouldQueue = false) {
const { rid, department } = inquiry;
const room = await LivechatRooms.findOneById(rid);
@ -181,6 +181,10 @@ export const RoutingManager: Routing = {
const { servedBy } = room;
if (shouldQueue) {
await LivechatInquiry.queueInquiry(inquiry._id);
}
if (servedBy) {
await LivechatRooms.removeAgentByRoomId(rid);
await this.removeAllRoomSubscriptions(room);

@ -21,6 +21,7 @@ declare module '@rocket.chat/ui-contexts' {
chatClosingTags?: string[];
fallbackForwardDepartment?: string;
departmentsAllowedToForward?: string[];
allowReceiveForwardOffline?: boolean;
},
departmentAgents?:
| {

@ -73,6 +73,7 @@ export type FormValues = {
fallbackForwardDepartment: string;
agentList: IDepartmentAgent[];
chatClosingTags: string[];
allowReceiveForwardOffline: boolean;
};
function withDefault<T>(key: T | undefined | null, defaultValue: T) {
@ -96,6 +97,7 @@ const getInitialValues = ({ department, agents, allowedToForwardData }: InitialV
fallbackForwardDepartment: withDefault(department?.fallbackForwardDepartment, ''),
chatClosingTags: department?.chatClosingTags ?? [],
agentList: agents || [],
allowReceiveForwardOffline: withDefault(department?.allowReceiveForwardOffline, false),
});
function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmentProps) {
@ -151,6 +153,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
waitingQueueMessage,
departmentsAllowedToForward,
fallbackForwardDepartment,
allowReceiveForwardOffline,
} = data;
const payload = {
@ -169,6 +172,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
waitingQueueMessage,
departmentsAllowedToForward: departmentsAllowedToForward?.map((dep) => dep.value),
fallbackForwardDepartment,
allowReceiveForwardOffline,
};
try {
@ -214,6 +218,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
const fallbackForwardDepartmentField = useUniqueId();
const requestTagBeforeClosingChatField = useUniqueId();
const chatClosingTagsField = useUniqueId();
const allowReceiveForwardOffline = useUniqueId();
return (
<Page flexDirection='row'>
@ -424,6 +429,15 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen
<ToggleSwitch id={requestTagBeforeClosingChatField} {...register('requestTagBeforeClosingChat')} />
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={allowReceiveForwardOffline}>{t('Accept_receive_inquiry_no_online_agents')}</FieldLabel>
<ToggleSwitch id={allowReceiveForwardOffline} {...register('allowReceiveForwardOffline')} />
</FieldRow>
<FieldRow>
<FieldHint id={`${allowReceiveForwardOffline}-hint`}>{t('Accept_receive_inquiry_no_online_agents_Hint')}</FieldHint>
</FieldRow>
</Field>
{requestTagBeforeClosingChat && (
<Field>
<FieldLabel htmlFor={chatClosingTagsField} required>

@ -232,6 +232,7 @@ export const LivechatEnterprise = {
chatClosingTags: Match.Optional([String]),
fallbackForwardDepartment: Match.Optional(String),
departmentsAllowedToForward: Match.Optional([String]),
allowReceiveForwardOffline: Match.Optional(Boolean),
};
// The Livechat Form department support addition/custom fields, so those fields need to be added before validating

@ -3,7 +3,7 @@ import { expect } from 'chai';
import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings';
import { api, credentials, methodCall, request } from '../api-data';
import { IUserCredentialsHeader } from '../user';
import { createAnOnlineAgent } from './users';
import { createAnOnlineAgent, createAnOfflineAgent } from './users';
import { WithRequiredProperty } from './utils';
export const NewDepartmentData = ((): Partial<ILivechatDepartment> => ({
@ -29,7 +29,9 @@ export const updateDepartment = async (departmentId: string, departmentData: Par
return response.body.department;
};
export const createDepartmentWithMethod = (initialAgents: { agentId: string, username: string }[] = []) =>
export const createDepartmentWithMethod = (
initialAgents: { agentId: string, username: string }[] = [],
allowReceiveForwardOffline = false) =>
new Promise((resolve, reject) => {
request
.post(methodCall('livechat:saveDepartment'))
@ -37,14 +39,19 @@ new Promise((resolve, reject) => {
.send({
message: JSON.stringify({
method: 'livechat:saveDepartment',
params: ['', {
enabled: true,
email: faker.internet.email(),
showOnRegistration: true,
showOnOfflineForm: true,
name: `new department ${Date.now()}`,
description: 'created from api',
}, initialAgents],
params: [
'',
{
enabled: true,
email: faker.internet.email(),
showOnRegistration: true,
showOnOfflineForm: true,
name: `new department ${Date.now()}`,
description: 'created from api',
allowReceiveForwardOffline,
},
initialAgents,
],
id: 'id',
msg: 'method',
}),
@ -102,6 +109,31 @@ export const addOrRemoveAgentFromDepartment = async (departmentId: string, agent
throw new Error('Failed to add or remove agent from department. Status code: ' + response.status + '\n' + response.body);
}
}
export const createDepartmentWithAnOfflineAgent = async ({
allowReceiveForwardOffline = false,
}: {
allowReceiveForwardOffline: boolean;
}): Promise<{
department: ILivechatDepartment;
agent: {
credentials: IUserCredentialsHeader;
user: WithRequiredProperty<IUser, 'username'>;
};
}> => {
const { user, credentials } = await createAnOfflineAgent();
const department = (await createDepartmentWithMethod(undefined, allowReceiveForwardOffline)) as ILivechatDepartment;
await addOrRemoveAgentFromDepartment(department._id, { agentId: user._id, username: user.username }, true);
return {
department,
agent: {
credentials,
user,
},
};
};
export const archiveDepartment = async (departmentId: string): Promise<void> => {
await request.post(api(`livechat/department/${ departmentId }/archive`)).set(credentials).expect(200);

@ -2,7 +2,7 @@ import { faker } from "@faker-js/faker";
import type { ILivechatAgent, IUser } from "@rocket.chat/core-typings";
import { IUserCredentialsHeader, password } from "../user";
import { createUser, login } from "../users.helper";
import { createAgent, makeAgentAvailable } from "./rooms";
import { createAgent, makeAgentAvailable, makeAgentUnavailable } from "./rooms";
import { api, credentials, request } from "../api-data";
export const createBotAgent = async (): Promise<{
@ -57,3 +57,21 @@ export const createAnOnlineAgent = async (): Promise<{
user: agent,
};
}
export const createAnOfflineAgent = async (): Promise<{
credentials: IUserCredentialsHeader;
user: IUser & { username: string };
}> => {
const username = `user.test.${Date.now()}.offline`;
const email = `${username}.offline@rocket.chat`;
const { body } = await request.post(api('users.create')).set(credentials).send({ email, name: username, username, password });
const agent = body.user;
const createdUserCredentials = await login(agent.username, password);
await createAgent(agent.username);
await makeAgentUnavailable(createdUserCredentials);
return {
credentials: createdUserCredentials,
user: agent,
};
};

@ -18,7 +18,7 @@ import type { Response } from 'supertest';
import type { SuccessResult } from '../../../../app/api/server/definition';
import { getCredentials, api, request, credentials, methodCall } from '../../../data/api-data';
import { createCustomField } from '../../../data/livechat/custom-fields';
import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department';
import { createDepartmentWithAnOfflineAgent, createDepartmentWithAnOnlineAgent, deleteDepartment } from '../../../data/livechat/department';
import { createSLA, getRandomPriority } from '../../../data/livechat/priorities';
import {
createVisitor,
@ -32,6 +32,7 @@ import {
closeOmnichannelRoom,
createDepartment,
fetchMessages,
makeAgentUnavailable,
} from '../../../data/livechat/rooms';
import { saveTags } from '../../../data/livechat/tags';
import type { DummyResponse } from '../../../data/livechat/utils';
@ -700,6 +701,35 @@ describe('LIVECHAT - rooms', function () {
await deleteUser(initialAgentAssignedToChat);
await deleteUser(forwardChatToUser);
});
(IS_EE ? it : it.skip)('should return error message when transferred to a offline agent', async () => {
await updateSetting('Livechat_Routing_Method', 'Auto_Selection');
const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent();
const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: false });
const newVisitor = await createVisitor(initialDepartment._id);
const newRoom = await createLivechatRoom(newVisitor.token);
await request
.post(api('livechat/room.forward'))
.set(credentials)
.send({
roomId: newRoom._id,
departmentId: forwardToOfflineDepartment._id,
clientAction: true,
comment: 'test comment',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-no-agents-online-in-department');
});
await deleteDepartment(initialDepartment._id);
await deleteDepartment(forwardToOfflineDepartment._id);
});
(IS_EE ? it : it.skip)('should return a success message when transferred successfully to a department', async () => {
const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent();
const { department: forwardToDepartment } = await createDepartmentWithAnOnlineAgent();
@ -734,6 +764,112 @@ describe('LIVECHAT - rooms', function () {
expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department');
expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToDepartment._id);
});
(IS_EE ? it : it.skip)(
'should return a success message when transferred successfully to an offline department when the department accepts it',
async () => {
const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent();
const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true });
const newVisitor = await createVisitor(initialDepartment._id);
const newRoom = await createLivechatRoom(newVisitor.token);
await request
.post(api('livechat/room.forward'))
.set(credentials)
.send({
roomId: newRoom._id,
departmentId: forwardToOfflineDepartment._id,
clientAction: true,
comment: 'test comment',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
});
await deleteDepartment(initialDepartment._id);
await deleteDepartment(forwardToOfflineDepartment._id);
},
);
(IS_EE ? it : it.skip)('inquiry should be taken automatically when agent on department is online again', async () => {
await updateSetting('Livechat_Routing_Method', 'Auto_Selection');
const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent();
const { department: forwardToOfflineDepartment } = await createDepartmentWithAnOfflineAgent({ allowReceiveForwardOffline: true });
const newVisitor = await createVisitor(initialDepartment._id);
const newRoom = await createLivechatRoom(newVisitor.token);
await request.post(api('livechat/room.forward')).set(credentials).send({
roomId: newRoom._id,
departmentId: forwardToOfflineDepartment._id,
clientAction: true,
comment: 'test comment',
});
await makeAgentAvailable();
const latestRoom = await getLivechatRoomInfo(newRoom._id);
expect(latestRoom).to.have.property('departmentId');
expect(latestRoom.departmentId).to.be.equal(forwardToOfflineDepartment._id);
expect(latestRoom).to.have.property('lastMessage');
expect(latestRoom.lastMessage?.t).to.be.equal('livechat_transfer_history');
expect(latestRoom.lastMessage?.u?.username).to.be.equal(adminUsername);
expect((latestRoom.lastMessage as any)?.transferData?.comment).to.be.equal('test comment');
expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department');
expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToOfflineDepartment._id);
await updateSetting('Livechat_Routing_Method', 'Manual_Selection');
await deleteDepartment(initialDepartment._id);
await deleteDepartment(forwardToOfflineDepartment._id);
});
(IS_EE ? it : it.skip)('when manager forward to offline department the inquiry should be set to the queue', async () => {
await updateSetting('Livechat_Routing_Method', 'Manual_Selection');
const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent();
const { department: forwardToOfflineDepartment, agent: offlineAgent } = await createDepartmentWithAnOfflineAgent({
allowReceiveForwardOffline: true,
});
const newVisitor = await createVisitor(initialDepartment._id);
const newRoom = await createLivechatRoom(newVisitor.token);
await makeAgentUnavailable(offlineAgent.credentials);
const manager: IUser = await createUser();
const managerCredentials = await login(manager.username, password);
await createManager(manager.username);
await request.post(api('livechat/room.forward')).set(managerCredentials).send({
roomId: newRoom._id,
departmentId: forwardToOfflineDepartment._id,
clientAction: true,
comment: 'test comment',
});
await request
.get(api(`livechat/queue`))
.set(credentials)
.query({
count: 1,
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body.queue).to.be.an('array');
expect(res.body.queue[0].chats).not.to.undefined;
expect(res.body).to.have.property('offset');
expect(res.body).to.have.property('total');
expect(res.body).to.have.property('count');
});
await deleteDepartment(initialDepartment._id);
await deleteDepartment(forwardToOfflineDepartment._id);
});
let roomId: string;
let visitorToken: string;
(IS_EE ? it : it.skip)('should return a success message when transferring to a fallback department', async () => {

@ -49,7 +49,16 @@ import { IS_EE } from '../../../e2e/config/constants';
const { body } = await request
.post(api('livechat/department'))
.set(credentials)
.send({ department: { name: 'Test', enabled: true, showOnOfflineForm: true, showOnRegistration: true, email: 'bla@bla' } })
.send({
department: {
name: 'Test',
enabled: true,
showOnOfflineForm: true,
showOnRegistration: true,
email: 'bla@bla',
allowReceiveForwardOffline: true,
},
})
.expect('Content-Type', 'application/json')
.expect(200);
expect(body).to.have.property('success', true);
@ -59,6 +68,8 @@ import { IS_EE } from '../../../e2e/config/constants';
expect(body.department).to.have.property('enabled', true);
expect(body.department).to.have.property('showOnOfflineForm', true);
expect(body.department).to.have.property('showOnRegistration', true);
expect(body.department).to.have.property('allowReceiveForwardOffline', true);
departmentId = body.department._id;
});

@ -17,6 +17,7 @@ export interface ILivechatDepartment {
departmentsAllowedToForward?: string[];
maxNumberSimultaneousChat?: number;
ancestors?: string[];
allowReceiveForwardOffline?: boolean;
// extra optional fields
[k: string]: any;
}
@ -32,4 +33,5 @@ export type LivechatDepartmentDTO = {
chatClosingTags?: string[] | undefined;
fallbackForwardDepartment?: string | undefined;
departmentsAllowedToForward?: string[] | undefined;
allowReceiveForwardOffline?: boolean;
};

@ -4841,6 +4841,8 @@
"Show_more": "Show more",
"Show_name_field": "Show name field",
"show_offline_users": "show offline users",
"Accept_receive_inquiry_no_online_agents": "Allow department to receive forwarded inquiries even when there's no available agents",
"Accept_receive_inquiry_no_online_agents_Hint": "This method is effective only with automatic assignment routing methods, and does not apply to Manual Selection.",
"Show_on_offline_page": "Show on offline page",
"Show_on_registration_page": "Show on registration page",
"Show_only_online": "Show Online Only",

Loading…
Cancel
Save