diff --git a/.changeset/quiet-bees-turn.md b/.changeset/quiet-bees-turn.md new file mode 100644 index 00000000000..e2312ccb478 --- /dev/null +++ b/.changeset/quiet-bees-turn.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/ddp-client": major +"@rocket.chat/livechat": major +"@rocket.chat/rest-typings": major +--- + +Removes the `livechat:transfer` deprecated method +Removes the `livechat/room.transfer` deprecated endpoint +Creates the `livechat/visitor.department.transfer` for visitors department transfer use diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 50b39b626cb..d8c751b17c9 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -6,13 +6,13 @@ import type { IUser, SelectedAgent, TransferByData, + TransferData, } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; -import { LivechatVisitors, Users, LivechatRooms, Messages } from '@rocket.chat/models'; +import { LivechatVisitors, Users, LivechatRooms } from '@rocket.chat/models'; import { isLiveChatRoomForwardProps, isPOSTLivechatRoomCloseParams, - isPOSTLivechatRoomTransferParams, isPOSTLivechatRoomSurveyParams, isLiveChatRoomJoinProps, isLiveChatRoomSaveInfoProps, @@ -24,7 +24,9 @@ import { validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, + ajv, } from '@rocket.chat/rest-typings'; +import { isPOSTLivechatVisitorDepartmentTransferParams } from '@rocket.chat/rest-typings/src/v1/omnichannel'; import { check } from 'meteor/check'; import { callbacks } from '../../../../../server/lib/callbacks'; @@ -236,43 +238,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'livechat/room.transfer', - { validateParams: isPOSTLivechatRoomTransferParams, deprecation: { version: '7.0.0' } }, - { - async post() { - const { rid, token, department } = this.bodyParams; - - const guest = await findGuest(token); - if (!guest) { - throw new Error('invalid-token'); - } - - let room = await findRoom(token, rid); - if (!room) { - throw new Error('invalid-room'); - } - - // update visited page history to not expire - await Messages.keepHistoryForToken(token); - - const { _id, username, name } = guest; - const transferredBy = normalizeTransferredByData({ _id, username, name, userType: 'visitor' }, room); - - if (!(await transfer(room, guest, { departmentId: department, transferredBy }))) { - return API.v1.failure(); - } - - room = await findRoom(token, rid); - if (!room) { - throw new Error('invalid-room'); - } - - return API.v1.success({ room }); - }, - }, -); - API.v1.addRoute( 'livechat/room.survey', { validateParams: isPOSTLivechatRoomSurveyParams }, @@ -365,6 +330,74 @@ API.v1.addRoute( }, ); +const livechatVisitorDepartmentTransfer = API.v1.post( + 'livechat/visitor/department.transfer', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + }, + body: isPOSTLivechatVisitorDepartmentTransferParams, + }, + async function action() { + const { rid, token, department } = this.bodyParams; + + const visitor = await findGuest(token); + if (!visitor) { + return API.v1.failure('invalid-token'); + } + const room = await LivechatRooms.findOneById(rid); + + if (!room || room.t !== 'l') { + return API.v1.failure('error-invalid-room'); + } + + if (!room.open) { + return API.v1.failure('This_conversation_is_already_closed'); + } + + // As this is a visitor endpoint, we should not show the mac limit error + if (!(await Omnichannel.isWithinMACLimit(room))) { + return API.v1.failure('error-transefing-chat'); + } + + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + if (!guest) { + return API.v1.failure('error-invalid-visitor'); + } + + const transferredBy = normalizeTransferredByData( + { _id: guest._id, username: guest.username, name: guest.name, userType: 'visitor' }, + room, + ); + + const transferData: TransferData = { transferredBy, departmentId: department }; + + const chatForwardedResult = await transfer(room, guest, transferData); + if (!chatForwardedResult) { + return API.v1.failure('error-transfering-chat'); + } + + return API.v1.success(); + }, +); + +type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI; +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends LivechatAnalyticsEndpoints {} +} + API.v1.addRoute( 'livechat/room.join', { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomJoinProps }, diff --git a/apps/meteor/app/livechat/server/index.ts b/apps/meteor/app/livechat/server/index.ts index 252a5110d2e..7b87c7c71ca 100644 --- a/apps/meteor/app/livechat/server/index.ts +++ b/apps/meteor/app/livechat/server/index.ts @@ -24,7 +24,6 @@ import './methods/saveCustomField'; import './methods/saveDepartment'; import './methods/sendMessageLivechat'; import './methods/sendFileLivechatMessage'; -import './methods/transfer'; import './methods/setUpConnection'; import './methods/takeInquiry'; import './methods/returnAsInquiry'; diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts deleted file mode 100644 index 9d01a944be8..00000000000 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Omnichannel } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; -import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { LivechatVisitors, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models'; -import { Match, check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; - -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; -import { normalizeTransferredByData } from '../lib/Helper'; -import { transfer } from '../lib/transfer'; - -declare module '@rocket.chat/ddp-client' { - // eslint-disable-next-line @typescript-eslint/naming-convention - interface ServerMethods { - 'livechat:transfer'(transferData: { - roomId: string; - userId?: string; - departmentId?: string; - comment?: string; - clientAction?: boolean; - }): boolean; - } -} - -// Deprecated in favor of "livechat/room.forward" endpoint -// TODO: Deprecated: Remove in v6.0.0 -Meteor.methods({ - async 'livechat:transfer'(transferData) { - methodDeprecationLogger.method('livechat:transfer', '7.0.0', '/v1/livechat/room.forward'); - const uid = Meteor.userId(); - if (!uid || !(await hasPermissionAsync(uid, 'view-l-room'))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:transfer' }); - } - - check(transferData, { - roomId: String, - userId: Match.Optional(String), - departmentId: Match.Optional(String), - comment: Match.Optional(String), - clientAction: Match.Optional(Boolean), - }); - - const room = await LivechatRooms.findOneById(transferData.roomId); - if (!room || room.t !== 'l') { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'livechat:transfer' }); - } - - if (!room.open) { - throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:transfer' }); - } - - if (!(await Omnichannel.isWithinMACLimit(room))) { - throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:transfer' }); - } - - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, { - projection: { _id: 1 }, - }); - if (!subscription && !(await hasPermissionAsync(uid, 'transfer-livechat-guest'))) { - throw new Meteor.Error('error-not-authorized', 'Not authorized', { - method: 'livechat:transfer', - }); - } - - const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); - - if (!guest) { - throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor', { method: 'livechat:transfer' }); - } - - const user = await Meteor.userAsync(); - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'livechat:transfer' }); - } - - const normalizedTransferData: { - roomId: string; - userId?: string; - departmentId?: string; - comment?: string; - clientAction?: boolean; - transferredBy: ReturnType; - transferredTo?: Pick; - } = { - ...transferData, - transferredBy: normalizeTransferredByData(user, room), - }; - - if (normalizedTransferData.userId) { - const userToTransfer = await Users.findOneById(normalizedTransferData.userId); - if (!userToTransfer) { - throw new Meteor.Error('error-invalid-user', 'Invalid user to transfer the room'); - } - normalizedTransferData.transferredTo = { - _id: userToTransfer._id, - username: userToTransfer.username, - name: userToTransfer.name, - }; - } - - return transfer(room, guest, normalizedTransferData); - }, -}); 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 3b8069aa50d..74b6a29bdbd 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 @@ -1895,6 +1895,131 @@ describe('LIVECHAT - rooms', () => { }); }); + describe('livechat/visitor/department.transfer', () => { + let initialDepartmentId: string; + let departmentForwardToId: string; + let omnichannelRoomId: string; + + afterEach(async () => { + await Promise.all([ + initialDepartmentId && deleteDepartment(initialDepartmentId), + departmentForwardToId && deleteDepartment(departmentForwardToId), + omnichannelRoomId && closeOmnichannelRoom(omnichannelRoomId), + updateSetting('Livechat_Routing_Method', 'Manual_Selection'), + ]); + }); + + it('should not be successful when no target (userId or departmentId) was specified', async () => { + await request + .post(api('livechat/visitor/department.transfer')) + .set(credentials) + .send({ + rid: room._id, + token: visitor.token, + department: 'invalid-department-id', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + + (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(); + initialDepartmentId = initialDepartment._id; + departmentForwardToId = forwardToDepartment._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + omnichannelRoomId = newRoom._id; + + await request + .post(api('livechat/visitor/department.transfer')) + .set(credentials) + .send({ + rid: newRoom._id, + token: newVisitor.token, + department: forwardToDepartment._id, + }) + .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(newVisitor.username); + 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 }); + initialDepartmentId = initialDepartment._id; + departmentForwardToId = forwardToOfflineDepartment._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + omnichannelRoomId = newRoom._id; + + await request + .post(api('livechat/visitor/department.transfer')) + .set(credentials) + .send({ + rid: newRoom._id, + token: newVisitor.token, + department: forwardToOfflineDepartment._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + }, + ); + (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 }); + initialDepartmentId = initialDepartment._id; + departmentForwardToId = forwardToOfflineDepartment._id; + + const newVisitor = await createVisitor(initialDepartment._id); + const newRoom = await createLivechatRoom(newVisitor.token); + omnichannelRoomId = newRoom._id; + + await request.post(api('livechat/visitor/department.transfer')).set(credentials).send({ + rid: newRoom._id, + token: newVisitor.token, + department: forwardToOfflineDepartment._id, + }); + + 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(newVisitor.username); + expect((latestRoom.lastMessage as any)?.transferData?.scope).to.be.equal('department'); + expect((latestRoom.lastMessage as any)?.transferData?.nextDepartment?._id).to.be.equal(forwardToOfflineDepartment._id); + }); + }); + describe('livechat/room.survey', () => { it('should return an "invalid-token" error when the visitor is not found due to an invalid token', async () => { await request diff --git a/packages/ddp-client/src/livechat/LivechatClientImpl.ts b/packages/ddp-client/src/livechat/LivechatClientImpl.ts index d214b1d28fb..4a4d1d623bf 100644 --- a/packages/ddp-client/src/livechat/LivechatClientImpl.ts +++ b/packages/ddp-client/src/livechat/LivechatClientImpl.ts @@ -204,11 +204,11 @@ export class LivechatClientImpl extends DDPSDK implements LivechatStream, Livech }: { rid: string; department: string; - }): Promise>> { + }): Promise>> { if (!this.token) { throw new Error('Invalid token'); } - return this.rest.post('/v1/livechat/room.transfer', { rid, token: this.token, department }); + return this.rest.post('/v1/livechat/visitor/department.transfer', { rid, token: this.token, department }); } async grantVisitor( diff --git a/packages/ddp-client/src/livechat/types/LivechatSDK.ts b/packages/ddp-client/src/livechat/types/LivechatSDK.ts index 259a09aef38..6e7237cdb25 100644 --- a/packages/ddp-client/src/livechat/types/LivechatSDK.ts +++ b/packages/ddp-client/src/livechat/types/LivechatSDK.ts @@ -55,8 +55,8 @@ export interface LivechatEndpoints { // POST transferChat( - args: OperationParams<'POST', '/v1/livechat/room.transfer'>, - ): Promise>>; + args: OperationParams<'POST', '/v1/livechat/visitor/department.transfer'>, + ): Promise>>; grantVisitor( guest: OperationParams<'POST', '/v1/livechat/visitor'>, ): Promise>>; diff --git a/packages/livechat/src/routes/SwitchDepartment/index.tsx b/packages/livechat/src/routes/SwitchDepartment/index.tsx index fea3dfc8fc6..011fa729312 100644 --- a/packages/livechat/src/routes/SwitchDepartment/index.tsx +++ b/packages/livechat/src/routes/SwitchDepartment/index.tsx @@ -1,4 +1,3 @@ -import type { IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings'; import { useContext } from 'preact/hooks'; import { route } from 'preact-router'; import { Controller, useForm } from 'react-hook-form'; @@ -76,18 +75,8 @@ const SwitchDepartment = (_: SwitchDepartmentProps) => { try { const { _id: rid } = room; const result = await Livechat.transferChat({ rid, department }); - // TODO: Investigate why the api results are not returning the correct type - const { success } = result as Serialized< - | { - room: IOmnichannelRoom; - success: boolean; - } - | { - room: IOmnichannelRoom; - success: boolean; - warning: string; - } - >; + const { success } = result; + if (!success) { throw t('no_available_agents_to_transfer'); } diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index fb6c803abc1..a46d670d79c 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -374,8 +374,8 @@ function setParentUrl(url: string) { callHook('setParentUrl', url); } -function transferChat(department: string) { - callHook('transferChat', department); +function transferChat(rid: string, department: string) { + callHook('transferChat', rid, department); } function setGuestMetadata(metadata: StoreState['iframe']['guestMetadata']) { diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 2ba7d959f9f..801396f3bc3 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -37,7 +37,6 @@ import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; import type { WithId } from 'mongodb'; import { ajv } from './Ajv'; -import type { Deprecated } from '../helpers/Deprecated'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -2913,13 +2912,13 @@ const POSTLivechatRoomCloseByUserParamsSchema = { export const isPOSTLivechatRoomCloseByUserParams = ajv.compile(POSTLivechatRoomCloseByUserParamsSchema); -type POSTLivechatRoomTransferParams = { +type POSTLivechatVisitorDepartmentTransferParams = { token: string; rid: string; department: string; }; -const POSTLivechatRoomTransferParamsSchema = { +const POSTLivechatVisitorDepartmentTransferParamsSchema = { type: 'object', properties: { token: { @@ -2936,7 +2935,9 @@ const POSTLivechatRoomTransferParamsSchema = { additionalProperties: false, }; -export const isPOSTLivechatRoomTransferParams = ajv.compile(POSTLivechatRoomTransferParamsSchema); +export const isPOSTLivechatVisitorDepartmentTransferParams = ajv.compile( + POSTLivechatVisitorDepartmentTransferParamsSchema, +); type POSTLivechatRoomSurveyParams = { token: string; @@ -4614,7 +4615,7 @@ export type OmnichannelEndpoints = { GET: (params: LiveChatRoomJoin) => void; }; '/v1/livechat/room.forward': { - POST: (params: LiveChatRoomForward) => void; + POST: (params: LiveChatRoomForward) => { success: boolean }; }; '/v1/livechat/room.saveInfo': { POST: (params: LiveChatRoomSaveInfo) => void; @@ -4944,8 +4945,8 @@ export type OmnichannelEndpoints = { '/v1/livechat/room.closeByUser': { POST: (params: POSTLivechatRoomCloseByUserParams) => void; }; - '/v1/livechat/room.transfer': { - POST: (params: POSTLivechatRoomTransferParams) => Deprecated<{ room: IOmnichannelRoom }>; + '/v1/livechat/visitor/department.transfer': { + POST: (params: POSTLivechatVisitorDepartmentTransferParams) => { success: boolean }; }; '/v1/livechat/room.survey': { POST: (params: POSTLivechatRoomSurveyParams) => { rid: string; data: unknown };