From 4aba7c8a263e11ba04f8c82322fe493850b3912e Mon Sep 17 00:00:00 2001 From: Allan RIbeiro <35040806+AllanPazRibeiro@users.noreply.github.com> Date: Fri, 5 Apr 2024 13:20:07 -0300 Subject: [PATCH] feat: allowing forward to offline dep (#31976) Co-authored-by: Guilherme Gazzo --- .changeset/strange-rivers-live.md | 8 + apps/meteor/app/livechat/server/lib/Helper.ts | 23 ++- .../app/livechat/server/lib/RoutingManager.ts | 8 +- .../livechat/server/methods/saveDepartment.ts | 1 + .../departments/EditDepartment.tsx | 14 ++ .../server/lib/LivechatEnterprise.ts | 1 + apps/meteor/tests/data/livechat/department.ts | 52 +++++-- apps/meteor/tests/data/livechat/users.ts | 20 ++- .../tests/end-to-end/api/livechat/00-rooms.ts | 138 +++++++++++++++++- .../end-to-end/api/livechat/10-departments.ts | 13 +- .../core-typings/src/ILivechatDepartment.ts | 2 + packages/i18n/src/locales/en.i18n.json | 2 + 12 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 .changeset/strange-rivers-live.md diff --git a/.changeset/strange-rivers-live.md b/.changeset/strange-rivers-live.md new file mode 100644 index 00000000000..b1ebd05c284 --- /dev/null +++ b/.changeset/strange-rivers-live.md @@ -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. diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 3f9a555d6b8..3c1d601250c 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -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 + >(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>(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'); diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 051053f761b..5f0a458dc31 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -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; - unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string): Promise; + unassignAgent(inquiry: ILivechatInquiryRecord, departmentId?: string, shouldQueue?: boolean): Promise; 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); diff --git a/apps/meteor/app/livechat/server/methods/saveDepartment.ts b/apps/meteor/app/livechat/server/methods/saveDepartment.ts index dd83a294cb0..45b3b2ec216 100644 --- a/apps/meteor/app/livechat/server/methods/saveDepartment.ts +++ b/apps/meteor/app/livechat/server/methods/saveDepartment.ts @@ -21,6 +21,7 @@ declare module '@rocket.chat/ui-contexts' { chatClosingTags?: string[]; fallbackForwardDepartment?: string; departmentsAllowedToForward?: string[]; + allowReceiveForwardOffline?: boolean; }, departmentAgents?: | { diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx index 0f64e41d242..30cad4142ff 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx @@ -73,6 +73,7 @@ export type FormValues = { fallbackForwardDepartment: string; agentList: IDepartmentAgent[]; chatClosingTags: string[]; + allowReceiveForwardOffline: boolean; }; function withDefault(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 ( @@ -424,6 +429,15 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen + + + {t('Accept_receive_inquiry_no_online_agents')} + + + + {t('Accept_receive_inquiry_no_online_agents_Hint')} + + {requestTagBeforeClosingChat && ( 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 6d0408dffc9..0e6a51cd0ff 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts @@ -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 diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index 3d18f9c394b..ba0df137b56 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -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 => ({ @@ -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; + }; +}> => { + 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 => { await request.post(api(`livechat/department/${ departmentId }/archive`)).set(credentials).expect(200); diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 3a21cbee923..161c20749b6 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 { 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, + }; +}; \ No newline at end of file diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 0d9e5fff0a6..e99c893abf9 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -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 () => { 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 54f8739efee..fc9af8d4580 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 @@ -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; }); diff --git a/packages/core-typings/src/ILivechatDepartment.ts b/packages/core-typings/src/ILivechatDepartment.ts index 8c75913dc72..a73cf55cb23 100644 --- a/packages/core-typings/src/ILivechatDepartment.ts +++ b/packages/core-typings/src/ILivechatDepartment.ts @@ -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; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 84cc8a1965f..6a13985f8a2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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",