diff --git a/apps/meteor/app/livechat/server/api/v1/room.js b/apps/meteor/app/livechat/server/api/v1/room.js index 4de41bef215f..b21dbaef6ba8 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.js +++ b/apps/meteor/app/livechat/server/api/v1/room.js @@ -3,6 +3,8 @@ import { Match, check } from 'meteor/check'; import { Random } from 'meteor/random'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users } from '@rocket.chat/models'; +import { isLiveChatRoomForwardProps } from '@rocket.chat/rest-typings'; import { settings as rcSettings } from '../../../../settings/server'; import { Messages, LivechatRooms } from '../../../../models/server'; @@ -195,10 +197,34 @@ API.v1.addRoute('livechat/room.survey', { API.v1.addRoute( 'livechat/room.forward', - { authRequired: true }, + { authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps }, { - post() { - API.v1.success(Meteor.runAsUser(this.userId, () => Meteor.call('livechat:transfer', this.bodyParams))); + async post() { + const transferData = this.bodyParams; + + const room = await LivechatRooms.findOneById(this.bodyParams.roomId); + if (!room || room.t !== 'l') { + throw new Error('error-invalid-room', 'Invalid room'); + } + + if (!room.open) { + throw new Error('This_conversation_is_already_closed'); + } + + const guest = await LivechatVisitors.findOneById(room.v && room.v._id); + transferData.transferredBy = normalizeTransferredByData(Meteor.user() || {}, room); + if (transferData.userId) { + const userToTransfer = await Users.findOneById(transferData.userId); + transferData.transferredTo = { + _id: userToTransfer._id, + username: userToTransfer.username, + name: userToTransfer.name, + }; + } + + const chatForwardedResult = await Livechat.transfer(room, guest, transferData); + + return chatForwardedResult ? API.v1.success() : API.v1.failure(); }, }, ); diff --git a/apps/meteor/app/livechat/server/lib/Helper.js b/apps/meteor/app/livechat/server/lib/Helper.js index 810fa421a5c0..97778819d2ac 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.js +++ b/apps/meteor/app/livechat/server/lib/Helper.js @@ -349,24 +349,18 @@ export const forwardRoomToAgent = async (room, transferData) => { const user = Users.findOneOnlineAgentById(agentId); if (!user) { logger.debug(`Agent ${agentId} is offline. Cannot forward`); - throw new Meteor.Error('error-user-is-offline', 'User is offline', { - function: 'forwardRoomToAgent', - }); + throw new Error('error-user-is-offline'); } const { _id: rid, servedBy: oldServedBy } = room; const inquiry = LivechatInquiry.findOneByRoomId(rid); if (!inquiry) { logger.debug(`No inquiries found for room ${room._id}. Cannot forward`); - throw new Meteor.Error('error-invalid-inquiry', 'Invalid inquiry', { - function: 'forwardRoomToAgent', - }); + throw new Error('error-invalid-inquiry'); } if (oldServedBy && agentId === oldServedBy._id) { - throw new Meteor.Error('error-selected-agent-room-agent-are-same', 'The selected agent and the room agent are the same', { - function: 'forwardRoomToAgent', - }); + throw new Error('error-selected-agent-room-agent-are-same'); } const { username } = user; @@ -445,16 +439,12 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { const inquiry = LivechatInquiry.findOneByRoomId(rid); if (!inquiry) { logger.debug(`Cannot forward room ${room._id}. No inquiries found`); - throw new Meteor.Error('error-transferring-inquiry'); + throw new Error('error-transferring-inquiry'); } const { departmentId } = transferData; if (oldDepartmentId === departmentId) { - throw new Meteor.Error( - 'error-forwarding-chat-same-department', - 'The selected department and the current room department are the same', - { function: 'forwardRoomToDepartment' }, - ); + throw new Error('error-forwarding-chat-same-department'); } const { userId: agentId, clientAction } = transferData; @@ -462,15 +452,11 @@ export const forwardRoomToDepartment = async (room, guest, transferData) => { logger.debug(`Forwarding room ${room._id} to department ${departmentId} (to user ${agentId})`); let user = Users.findOneOnlineAgentById(agentId); if (!user) { - throw new Meteor.Error('error-user-is-offline', 'User is offline', { - function: 'forwardRoomToAgent', - }); + throw new Error('error-user-is-offline'); } user = LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(agentId, departmentId); if (!user) { - throw new Meteor.Error('error-user-not-belong-to-department', 'The selected user does not belong to this department', { - function: 'forwardRoomToDepartment', - }); + throw new Error('error-user-not-belong-to-department'); } const { username } = user; agent = { agentId, username }; diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index db32b1c76d40..17e7cd36ce0b 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -710,14 +710,29 @@ export const Livechat = { }, }; - return Messages.createTransferHistoryWithRoomIdMessageAndUser(room._id, '', { _id, username }, transfer); + const type = 'livechat_transfer_history'; + const transferMessage = { + t: type, + rid: room._id, + ts: new Date(), + msg: '', + u: { + _id, + username, + }, + groupable: false, + }; + + Object.assign(transferMessage, transfer); + + sendMessage(transferredBy, transferMessage, room); }, async transfer(room, guest, transferData) { Livechat.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); if (room.onHold) { Livechat.logger.debug('Cannot transfer. Room is on hold'); - throw new Meteor.Error('error-room-onHold', 'Room On Hold', { method: 'livechat:transfer' }); + throw new Error('error-room-onHold'); } if (transferData.departmentId) { diff --git a/apps/meteor/app/livechat/server/methods/transfer.js b/apps/meteor/app/livechat/server/methods/transfer.js index 52a189c97e5c..f9f556386aca 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.js +++ b/apps/meteor/app/livechat/server/methods/transfer.js @@ -7,6 +7,8 @@ import { LivechatRooms, Subscriptions, Users } from '../../../models/server'; import { Livechat } from '../lib/Livechat'; import { normalizeTransferredByData } from '../lib/Helper'; +// Deprecated in favor of "livechat/room.forward" endpoint +// TODO: Deprecated: Remove in v6.0.0 Meteor.methods({ async 'livechat:transfer'(transferData) { if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { diff --git a/apps/meteor/app/models/server/models/Messages.js b/apps/meteor/app/models/server/models/Messages.js index 813568a6234f..e570c67ed779 100644 --- a/apps/meteor/app/models/server/models/Messages.js +++ b/apps/meteor/app/models/server/models/Messages.js @@ -818,29 +818,6 @@ export class Messages extends Base { return record; } - createTransferHistoryWithRoomIdMessageAndUser(roomId, message, user, extraData) { - const type = 'livechat_transfer_history'; - const record = { - t: type, - rid: roomId, - ts: new Date(), - msg: message, - u: { - _id: user._id, - username: user.username, - }, - groupable: false, - }; - - if (settings.get('Message_Read_Receipt_Enabled')) { - record.unread = true; - } - Object.assign(record, extraData); - - record._id = this.insertOrUpsert(record); - return record; - } - createTranscriptHistoryWithRoomIdMessageAndUser(roomId, message, user, extraData) { const type = 'livechat_transcript_history'; const record = { diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx index 12a5dc517e19..99fd355894da 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx @@ -138,7 +138,7 @@ export const useQuickActions = ( } }, [closeModal, discardTranscript, dispatchToastMessage, rid, t]); - const forwardChat = useMethod('livechat:transfer'); + const forwardChat = useEndpoint('POST', '/v1/livechat/room.forward'); const handleForwardChat = useCallback( async (departmentId?: string, userId?: string, comment?: string) => { @@ -173,7 +173,7 @@ export const useQuickActions = ( FlowRouter.go('/'); closeModal(); } catch (error: any) { - handleError(error); + dispatchToastMessage({ type: 'error', message: error as any }); } }, [closeModal, dispatchToastMessage, forwardChat, rid, t], 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 d95326f1a28b..0fc525c84984 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.ts @@ -1,4 +1,3 @@ -import { Meteor } from 'meteor/meteor'; import { Users } from '@rocket.chat/models'; import { callbacks } from '../../../../../lib/callbacks'; @@ -73,7 +72,7 @@ callbacks.add( cbLogger.debug('Callback with error. Agent reached max amount of simultaneous chats'); callbacks.run('livechat.onMaxNumberSimultaneousChatsReached', inquiry); if (options.clientAction && !options.forwardingToDepartment) { - throw new Meteor.Error('error-max-number-simultaneous-chats-reached', 'Not allowed'); + throw new Error('error-max-number-simultaneous-chats-reached'); } return null; diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index e8b8b313f44f..8a04f4297b46 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -1,5 +1,10 @@ -import type { ILivechatDepartment } from '@rocket.chat/core-typings'; -import { api, credentials, request } from '../api-data'; +import faker from '@faker-js/faker'; +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { api, credentials, methodCall, request } from '../api-data'; +import { password } from '../user'; +import { createUser, login } from '../users.helper'; +import { createAgent, makeAgentAvailable } from './rooms'; +import { DummyResponse } from './utils'; export const createDepartment = (): Promise => new Promise((resolve, reject) => { @@ -16,10 +21,64 @@ export const createDepartment = (): Promise => }, }) .set(credentials) - .end((err, res) => { + .end((err: Error, res: DummyResponse) => { if (err) { return reject(err); } resolve(res.body.department); }); }); + +export const createDepartmentWithMethod = (initialAgents: { agentId: string, username: string }[] = []) => +new Promise((resolve, reject) => { + request + .post(methodCall('livechat:saveDepartment')) + .set(credentials) + .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], + id: 'id', + msg: 'method', + }), + }) + .end((err: any, res: any) => { + if (err) { + return reject(err); + } + resolve(JSON.parse(res.body.message).result); + }); +}); + +export const createDepartmentWithAnOnlineAgent = async (): Promise<{department: ILivechatDepartment, agent: IUser}> => { + const agent: IUser = await createUser(); + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentAvailable(createdUserCredentials); + + const department = await createDepartmentWithMethod() as ILivechatDepartment; + + await addOrRemoveAgentFromDepartment(department._id, {agentId: agent._id, username: (agent.username as string)}, true); + + return { + department, + agent, + }; +}; + +export const addOrRemoveAgentFromDepartment = async (departmentId: string, agent: { agentId: string; username: string; count?: number; order?: number }, add: boolean) => { + const response = await request.post(api('livechat/department/' + departmentId + '/agents')).set(credentials).send({ + ...add ? { upsert: [agent], remove: [] } : { remove: [agent], upsert: [] }, + }); + + if (response.status !== 200) { + throw new Error('Failed to add or remove agent from department. Status code: ' + response.status + '\n' + response.body); + } +} diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index 6a4c0c6daebb..14904bcba5bd 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -1,9 +1,7 @@ import type { IInquiry, ILivechatAgent, ILivechatDepartment, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { api, credentials, methodCall, request } from '../api-data'; import { adminUsername } from '../user'; - -type DummyResponse = - E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T }; +import { DummyResponse } from './utils'; export const createLivechatRoom = (visitorToken: string): Promise => new Promise((resolve) => { @@ -13,7 +11,7 @@ export const createLivechatRoom = (visitorToken: string): Promise) => resolve(res.body.room)); }); -export const createVisitor = (): Promise => +export const createVisitor = (department?: string): Promise => new Promise((resolve, reject) => { const token = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); const email = `${token}@${token}.com`; @@ -32,6 +30,7 @@ export const createVisitor = (): Promise => token, phone, customFields: [{ key: 'address', value: 'Rocket.Chat street', overwrite: true }], + ...(department ? { department } : {}), }, }) .end((err: Error, res: DummyResponse) => { @@ -94,13 +93,13 @@ export const createDepartment = (agents?: { agentId: string }[]): Promise => +export const createAgent = (overrideUsername?: string): Promise => new Promise((resolve, reject) => { request .post(api('livechat/users/agent')) .set(credentials) .send({ - username: adminUsername, + username: overrideUsername || adminUsername, }) .end((err: Error, res: DummyResponse) => { if (err) { @@ -126,9 +125,9 @@ export const createManager = (): Promise => }); }); -export const makeAgentAvailable = (): Promise => +export const makeAgentAvailable = (overrideCredentials?: { 'X-Auth-Token': string; 'X-User-Id': string }): Promise => new Promise((resolve, reject) => { - request.post(api('users.setStatus')).set(credentials).send({ + request.post(api('users.setStatus')).set(overrideCredentials || credentials).send({ message: '', status: 'online', }).end((err: Error, _res: DummyResponse) => { @@ -137,7 +136,7 @@ export const makeAgentAvailable = (): Promise => } request .post(methodCall('livechat/changeLivechatStatus')) - .set(credentials) + .set(overrideCredentials || credentials) .send({ message: JSON.stringify({ method: 'livechat/changeLivechatStatus', @@ -155,3 +154,17 @@ export const makeAgentAvailable = (): Promise => }); }); + +export const getLivechatRoomInfo = (roomId: string): Promise => { + return new Promise((resolve /* , reject*/) => { + request + .get(api('channels.info')) + .set(credentials) + .query({ + roomId, + }) + .end((_err: Error, req: DummyResponse) => { + resolve(req.body.channel); + }); + }); +} diff --git a/apps/meteor/tests/data/livechat/utils.ts b/apps/meteor/tests/data/livechat/utils.ts new file mode 100644 index 000000000000..676d3edb8d63 --- /dev/null +++ b/apps/meteor/tests/data/livechat/utils.ts @@ -0,0 +1,2 @@ +export type DummyResponse = + E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T }; 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 308f7a61fb0c..7a9eb6718534 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 @@ -4,12 +4,15 @@ import fs from 'fs'; import path from 'path'; import { expect } from 'chai'; -import type { IOmnichannelRoom, ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, ILivechatVisitor, IUser, IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; -import { createVisitor, createLivechatRoom, createAgent, makeAgentAvailable } from '../../../data/livechat/rooms'; +import { createVisitor, createLivechatRoom, createAgent, makeAgentAvailable, getLivechatRoomInfo } from '../../../data/livechat/rooms'; import { updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { createUser, login } from '../../../data/users.helper.js'; +import { adminUsername, password } from '../../../data/user.js'; +import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; describe('LIVECHAT - rooms', function () { this.retries(0); @@ -246,6 +249,137 @@ describe('LIVECHAT - rooms', function () { }); }); + describe('livechat/room.forward', () => { + it('should return an "unauthorized error" when the user does not have "view-l-room" permission', async () => { + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('view-l-room', []); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.have.string('unauthorized'); + }); + }); + + it('should return an "unauthorized error" when the user does not have "transfer-livechat-guest" permission', async () => { + await updatePermission('transfer-livechat-guest', []); + await updatePermission('view-l-room', ['admin']); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: 'invalid-room-id', + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.have.string('unauthorized'); + }); + }); + + it('should not be successful when no target (userId or departmentId) was specified', async () => { + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('view-l-room', ['admin']); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: room._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should return a success message when transferred successfully to agent', async () => { + const initialAgentAssignedToChat: IUser = await createUser(); + const initialAgentCredentials = await login(initialAgentAssignedToChat.username, password); + await createAgent(initialAgentAssignedToChat.username); + await makeAgentAvailable(initialAgentCredentials); + + const newVisitor = await createVisitor(); + // at this point, the chat will get transferred to agent "user" + const newRoom = await createLivechatRoom(newVisitor.token); + + const forwardChatToUser: IUser = await createUser(); + const forwardChatToUserCredentials = await login(forwardChatToUser.username, password); + await createAgent(forwardChatToUser.username); + await makeAgentAvailable(forwardChatToUserCredentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId: newRoom._id, + userId: forwardChatToUser._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._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); + const { lastMessage } = latestRoom as { lastMessage: IOmnichannelSystemMessage }; + expect(lastMessage?.transferData?.comment).to.be.equal('test comment'); + expect(lastMessage?.transferData?.scope).to.be.equal('agent'); + expect(lastMessage?.transferData?.transferredTo?.username).to.be.equal(forwardChatToUser.username); + }); + + it('should return a success message when transferred successfully to a department', async () => { + const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); + const { department: forwardToDepartment } = await createDepartmentWithAnOnlineAgent(); + + 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: forwardToDepartment._id, + clientAction: true, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const latestRoom = await getLivechatRoomInfo(newRoom._id); + + expect(latestRoom).to.have.property('departmentId'); + expect(latestRoom.departmentId).to.be.equal(forwardToDepartment._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(forwardToDepartment._id); + }); + }); + describe('livechat/room.survey', () => { it('should return an "invalid-token" error when the visitor is not found due to an invalid token', (done) => { request diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index a0314de8c41e..332f8b29d555 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -268,6 +268,43 @@ const LiveChatRoomJoinSchema = { export const isLiveChatRoomJoinProps = ajv.compile(LiveChatRoomJoinSchema); +type LiveChatRoomForward = { + roomId: string; + userId?: string; + departmentId?: string; + comment?: string; + clientAction?: boolean; +}; + +const LiveChatRoomForwardSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + userId: { + type: 'string', + nullable: true, + }, + departmentId: { + type: 'string', + nullable: true, + }, + comment: { + type: 'string', + nullable: true, + }, + clientAction: { + type: 'boolean', + nullable: true, + }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +export const isLiveChatRoomForwardProps = ajv.compile(LiveChatRoomForwardSchema); + type LivechatMonitorsListProps = PaginatedRequest<{ text: string }>; const LivechatMonitorsListSchema = { @@ -1030,6 +1067,9 @@ export type OmnichannelEndpoints = { '/v1/livechat/room.join': { GET: (params: LiveChatRoomJoin) => { success: boolean }; }; + '/v1/livechat/room.forward': { + POST: (params: LiveChatRoomForward) => { success: boolean }; + }; '/v1/livechat/monitors': { GET: (params: LivechatMonitorsListProps) => PaginatedResult<{ monitors: ILivechatMonitor[];