[NEW] Add new endpoint 'livechat/room.saveInfo' & deprecate 'livechat:saveInfo' meteor method (#26789)

pull/27076/head^2
Murtaza Patrawala 3 years ago committed by GitHub
parent 5f5730c937
commit 86ffdcaa7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 37
      apps/meteor/app/livechat/server/api/v1/room.ts
  2. 5
      apps/meteor/app/livechat/server/lib/Livechat.js
  3. 7
      apps/meteor/app/livechat/server/methods/saveInfo.js
  4. 11
      apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js
  5. 4
      apps/meteor/tests/data/livechat/utils.ts
  6. 257
      apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts
  7. 4
      packages/core-typings/src/IRoom.ts
  8. 90
      packages/rest-typings/src/v1/omnichannel.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();
},
},
);

@ -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 !== '') {

@ -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')) {

@ -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();

@ -1,2 +1,6 @@
export type DummyResponse<T, E = 'wrapped'> =
E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T };
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
}

@ -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);
});
});
});

@ -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 {

@ -315,6 +315,93 @@ const LiveChatRoomForwardSchema = {
export const isLiveChatRoomForwardProps = ajv.compile<LiveChatRoomForward>(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<LiveChatRoomSaveInfo>(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[];

Loading…
Cancel
Save