diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 6bb4208f2a0..ead2961af7c 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -3,7 +3,7 @@ import { check } from 'meteor/check'; import { Random } from 'meteor/random'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { OmnichannelSourceType } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, Users } from '@rocket.chat/models'; import { isLiveChatRoomForwardProps, @@ -12,6 +12,7 @@ import { isPOSTLivechatRoomSurveyParams, isLiveChatRoomJoinProps, isPUTLivechatRoomVisitorParams, + isLiveChatRoomSaveInfoProps, } from '@rocket.chat/rest-typings'; import { settings as rcSettings } from '../../../../settings/server'; @@ -21,10 +22,11 @@ import { findGuest, findRoom, getRoom, settings, findAgent, onCheckRoomParams } import { Livechat } from '../../lib/Livechat'; import { normalizeTransferredByData } from '../../lib/Helper'; import { findVisitorInfo } from '../lib/visitors'; -import { canAccessRoom } from '../../../../authorization/server'; +import { canAccessRoom, hasPermission } from '../../../../authorization/server'; import { addUserToRoom } from '../../../../lib/server/functions'; import { apiDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { deprecationWarning } from '../../../../api/server/helpers/deprecationWarning'; +import { callbacks } from '../../../../../lib/callbacks'; API.v1.addRoute('livechat/room', { async get() { @@ -293,3 +295,34 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'livechat/room.saveInfo', + { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomSaveInfoProps }, + { + async post() { + const { roomData, guestData } = this.bodyParams; + const room = await LivechatRooms.findOneById(roomData._id); + if (!room || !isOmnichannelRoom(room)) { + throw new Error('error-invalid-room'); + } + + if ((!room.servedBy || room.servedBy._id !== this.userId) && !hasPermission(this.userId, 'save-others-livechat-room-info')) { + return API.v1.unauthorized(); + } + + if (room.sms) { + delete guestData.phone; + } + + await Promise.allSettled([Livechat.saveGuest(guestData, this.userId), Livechat.saveRoomInfo(roomData)]); + + callbacks.run('livechat.saveInfo', LivechatRooms.findOneById(roomData._id), { + user: this.user, + oldRoom: room, + }); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 60539a9503f..951b8944380 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -376,7 +376,8 @@ export const Livechat = { return false; }, - async saveGuest({ _id, name, email, phone, livechatData = {} }, userId) { + async saveGuest(guestData, userId) { + const { _id, name, email, phone, livechatData = {} } = guestData; Livechat.logger.debug(`Saving data for visitor ${_id}`); const updateData = {}; @@ -591,7 +592,7 @@ export const Livechat = { const fields = LivechatCustomField.findByScope('room'); for await (const field of fields) { if (!livechatData.hasOwnProperty(field._id)) { - return; + continue; } const value = s.trim(livechatData[field._id]); if (value !== '' && field.regexp !== undefined && field.regexp !== '') { diff --git a/apps/meteor/app/livechat/server/methods/saveInfo.js b/apps/meteor/app/livechat/server/methods/saveInfo.js index 9c944d65d2b..1357e2408fc 100644 --- a/apps/meteor/app/livechat/server/methods/saveInfo.js +++ b/apps/meteor/app/livechat/server/methods/saveInfo.js @@ -6,9 +6,16 @@ import { hasPermission } from '../../../authorization'; import { LivechatRooms } from '../../../models/server'; import { callbacks } from '../../../../lib/callbacks'; import { Livechat } from '../lib/Livechat'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; +/** + * @deprecated Will be removed in future versions. + */ Meteor.methods({ async 'livechat:saveInfo'(guestData, roomData) { + methodDeprecationLogger.warn( + 'livechat:saveInfo method will be deprecated in future versions of Rocket.Chat. Use "livechat/room.saveInfo" endpoint instead.', + ); const userId = Meteor.userId(); if (!userId || !hasPermission(userId, 'view-l-room')) { diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js index e819921ad55..bb243692011 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js @@ -1,6 +1,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; @@ -88,11 +88,11 @@ function RoomEdit({ room, visitor, reload, reloadInfo, close }) { const dispatchToastMessage = useToastMessageDispatch(); - const saveRoom = useMethod('livechat:saveInfo'); + const saveRoom = useEndpoint('POST', '/v1/livechat/room.saveInfo'); const handleSave = useMutableCallback(async (e) => { e.preventDefault(); - const userData = { + const guestData = { _id: visitor._id, }; @@ -105,7 +105,10 @@ function RoomEdit({ room, visitor, reload, reloadInfo, close }) { }; try { - saveRoom(userData, roomData); + await saveRoom({ + guestData, + roomData, + }); dispatchToastMessage({ type: 'success', message: t('Saved') }); reload && reload(); reloadInfo && reloadInfo(); diff --git a/apps/meteor/tests/data/livechat/utils.ts b/apps/meteor/tests/data/livechat/utils.ts index 676d3edb8d6..89b6af709fb 100644 --- a/apps/meteor/tests/data/livechat/utils.ts +++ b/apps/meteor/tests/data/livechat/utils.ts @@ -1,2 +1,6 @@ export type DummyResponse = E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T }; + +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +} 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 4c834dd8106..f633fd7a538 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,8 +4,9 @@ import fs from 'fs'; import path from 'path'; import { expect } from 'chai'; -import type { IOmnichannelRoom, ILivechatVisitor, IUser, IOmnichannelSystemMessage } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, ILivechatVisitor, IUser, IOmnichannelSystemMessage, ILivechatCustomField } from '@rocket.chat/core-typings'; import type { Response } from 'supertest'; +import faker from '@faker-js/faker'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; import { @@ -20,6 +21,9 @@ import { updatePermission, updateSetting } from '../../../data/permissions.helpe import { createUser, login } from '../../../data/users.helper.js'; import { adminUsername, password } from '../../../data/user.js'; import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { sleep } from '../../../data/livechat/utils'; +import { IS_EE } from '../../../e2e/config/constants'; +import { createCustomField } from '../../../data/livechat/custom-fields'; describe('LIVECHAT - rooms', function () { this.retries(0); @@ -1015,6 +1019,7 @@ describe('LIVECHAT - rooms', function () { expect(body.messages[1]).to.have.property('username', visitor.username); }); }); + describe('livechat/transfer.history/:rid', () => { it('should fail if user doesnt have "view-livechat-rooms" permission', async () => { await updatePermission('view-livechat-rooms', []); @@ -1090,4 +1095,254 @@ describe('LIVECHAT - rooms', function () { expect(body.history[0]).to.have.property('transferredBy').that.is.an('object'); }); }); + + describe('livechat/room.saveInfo', () => { + it('should fail if no data is sent as body param', async () => { + await request.post(api('livechat/room.saveInfo')).set(credentials).expect('Content-Type', 'application/json').expect(400); + }); + + it('should return an "unauthorized error" when the user does not have "view-l-room" permission', async () => { + await updatePermission('view-l-room', []); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: 'invalid-room-id', + }, + guestData: { + _id: 'invalid-guest-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 allow users to update room info without serving the chat or having "save-others-livechat-room-info" permission', async () => { + await updatePermission('view-l-room', ['admin']); + await updatePermission('save-others-livechat-room-info', []); + + await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); + // delay for 1 second to make sure the routing queue gets stopped + await sleep(1000); + + const newVisitor = await createVisitor(); + // at this point, the chat will get transferred to agent "user" + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: newRoom._id, + }, + guestData: { + _id: newVisitor._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'); + }); + + await updatePermission('save-others-livechat-room-info', ['admin']); + await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); + // delay for 1 second to make sure the routing queue starts again + await sleep(1000); + }); + + it('should throw an error if roomData is not provided', async () => { + await updatePermission('view-l-room', ['admin']); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + guestData: { + _id: 'invalid-guest-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if guestData is not provided', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: 'invalid-room-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if roomData is not of valid type', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: 'invalid-room-data', + guestData: { + _id: 'invalid-guest-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if guestData is not of valid type', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + guestData: 'invalid-guest-data', + roomData: { + _id: 'invalid-room-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should allow user to update the room info', async () => { + await updatePermission('view-l-room', ['admin']); + + const newVisitor = await createVisitor(); + // at this point, the chat will get transferred to agent "user" + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: newRoom._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + }, + guestData: { + _id: newVisitor._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('topic', 'new topic'); + expect(latestRoom).to.have.property('tags').of.length(2); + expect(latestRoom).to.have.property('tags').to.include('tag1'); + expect(latestRoom).to.have.property('tags').to.include('tag2'); + }); + + (IS_EE ? it : it.skip)('should allow user to update the room info - EE fields', async () => { + const cfName = faker.lorem.word(); + await createCustomField({ + searchable: true, + field: cfName, + label: cfName, + scope: 'room', + visibility: 'visible', + regexp: '', + } as unknown as ILivechatCustomField & { field: string }); + + const newVisitor = await createVisitor(); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: newRoom._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + livechatData: { + [cfName]: 'test-input-1-value', + }, + }, + guestData: { + _id: newVisitor._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('topic', 'new topic'); + expect(latestRoom).to.have.property('tags').of.length(2); + expect(latestRoom).to.have.property('tags').to.include('tag1'); + expect(latestRoom).to.have.property('tags').to.include('tag2'); + expect(latestRoom).to.have.property('livechatData').to.have.property(cfName, 'test-input-1-value'); + }); + + (IS_EE ? it : it.skip)('endpoint should handle empty custom fields', async () => { + const newVisitor = await createVisitor(); + const newRoom = await createLivechatRoom(newVisitor.token); + + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: newRoom._id, + topic: 'new topic', + tags: ['tag1', 'tag2'], + livechatData: {}, + }, + guestData: { + _id: newVisitor._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('topic', 'new topic'); + expect(latestRoom).to.have.property('tags').of.length(2); + expect(latestRoom).to.have.property('tags').to.include('tag1'); + expect(latestRoom).to.have.property('tags').to.include('tag2'); + expect(latestRoom).to.not.have.property('livechatData'); + }); + + (IS_EE ? it : it.skip)('should throw an error if custom fields are not valid', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: 'invalid-room-id', + livechatData: { + key: { + value: 'invalid', + }, + }, + }, + guestData: { + _id: 'invalid-visitor-id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + }); }); diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 289e48294eb..fc53bb2775b 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -194,6 +194,10 @@ export interface IOmnichannelRoom extends IOmnichannelGenericRoom { omnichannel?: { predictedVisitorAbandonmentAt: Date; }; + // sms field is used when the room is created from one of the internal SMS integrations (e.g. Twilio) + sms?: { + from: string; + }; } export interface IVoipRoom extends IOmnichannelGenericRoom { diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index e940eacd785..04c8e884596 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -315,6 +315,93 @@ const LiveChatRoomForwardSchema = { export const isLiveChatRoomForwardProps = ajv.compile(LiveChatRoomForwardSchema); +type LiveChatRoomSaveInfo = { + guestData: { + _id: string; + name?: string; + email?: string; + phone?: string; + livechatData?: { [k: string]: string }; + }; + roomData: { + _id: string; + topic?: string; + tags?: string[]; + livechatData?: { [k: string]: string }; + priorityId?: string; + }; +}; + +const LiveChatRoomSaveInfoSchema = { + type: 'object', + properties: { + guestData: { + type: 'object', + properties: { + _id: { + type: 'string', + }, + name: { + type: 'string', + nullable: true, + }, + email: { + type: 'string', + nullable: true, + }, + phone: { + type: 'string', + nullable: true, + }, + livechatData: { + type: 'object', + patternProperties: { + '.*': { + type: 'string', + }, + }, + nullable: true, + }, + }, + required: ['_id'], + additionalProperties: false, + }, + roomData: { + type: 'object', + properties: { + _id: { + type: 'string', + }, + topic: { + type: 'string', + nullable: true, + }, + tags: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + livechatData: { + type: 'object', + nullable: true, + }, + priorityId: { + type: 'string', + nullable: true, + }, + }, + required: ['_id'], + additionalProperties: false, + }, + }, + required: ['guestData', 'roomData'], + additionalProperties: false, +}; + +export const isLiveChatRoomSaveInfoProps = ajv.compile(LiveChatRoomSaveInfoSchema); + type LivechatMonitorsListProps = PaginatedRequest<{ text: string }>; const LivechatMonitorsListSchema = { @@ -2613,6 +2700,9 @@ export type OmnichannelEndpoints = { '/v1/livechat/room.forward': { POST: (params: LiveChatRoomForward) => void; }; + '/v1/livechat/room.saveInfo': { + POST: (params: LiveChatRoomSaveInfo) => void; + }; '/v1/livechat/monitors': { GET: (params: LivechatMonitorsListProps) => PaginatedResult<{ monitors: ILivechatMonitor[];