From acdb7f93d151e4f30eab2f6da4d6d7d946a3b031 Mon Sep 17 00:00:00 2001
From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>
Date: Tue, 28 Jun 2022 01:10:52 +0530
Subject: [PATCH] [IMPROVE] Differ Voip calls from Incoming and Outgoing
(#25643)
## Proposed changes (including videos or screenshots)
Updated this column and its respective endpoints to support inbound/outfound call definitions

## Issue(s)
## Steps to test or reproduce
## Further comments
Clickup: https://app.clickup.com/t/22bmc0f
Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com>
---
apps/meteor/app/api/server/v1/voip/rooms.ts | 56 ++++++++++++-------
.../providers/CallProvider/CallProvider.tsx | 2 +-
.../omnichannel/directory/calls/CallTable.tsx | 26 ++++++---
.../rocketchat-i18n/i18n/en.i18n.json | 1 +
apps/meteor/server/models/raw/VoipRoom.ts | 11 ++++
.../sdk/types/IOmnichannelVoipService.ts | 1 +
.../omnichannel-voip/internalTypes.ts | 4 +-
.../services/omnichannel-voip/service.ts | 9 ++-
.../meteor/server/startup/migrations/index.ts | 1 +
apps/meteor/server/startup/migrations/v270.ts | 21 +++++++
apps/meteor/tests/data/rooms.helper.js | 4 +-
packages/core-typings/src/IRoom.ts | 2 +
.../src/models/IVoipRoomModel.ts | 4 ++
packages/rest-typings/src/v1/voip.ts | 37 +++++++++++-
14 files changed, 144 insertions(+), 35 deletions(-)
create mode 100644 apps/meteor/server/startup/migrations/v270.ts
diff --git a/apps/meteor/app/api/server/v1/voip/rooms.ts b/apps/meteor/app/api/server/v1/voip/rooms.ts
index 1bc70ce928b..1199f67044b 100644
--- a/apps/meteor/app/api/server/v1/voip/rooms.ts
+++ b/apps/meteor/app/api/server/v1/voip/rooms.ts
@@ -1,8 +1,7 @@
-import { Match, check } from 'meteor/check';
import { Random } from 'meteor/random';
-import type { ILivechatAgent } from '@rocket.chat/core-typings';
+import type { ILivechatAgent, IVoipRoom } from '@rocket.chat/core-typings';
+import { isVoipRoomProps, isVoipRoomsProps, isVoipRoomCloseProps } from '@rocket.chat/rest-typings/dist/v1/voip';
import { VoipRoom, LivechatVisitors, Users } from '@rocket.chat/models';
-import { isVoipRoomCloseProps } from '@rocket.chat/rest-typings/dist/v1/voip';
import { API } from '../../api';
import { LivechatVoip } from '../../../../../server/sdk';
@@ -25,6 +24,7 @@ const validateDateParams = (property: string, date: DateParam = {}): DateParam =
const parseAndValidate = (property: string, date?: string): DateParam => {
return validateDateParams(property, parseDateParams(date));
};
+
/**
* @openapi
* /voip/server/api/v1/voip/room
@@ -81,23 +81,38 @@ const parseAndValidate = (property: string, date?: string): DateParam => {
* $ref: '#/components/schemas/ApiFailureV1'
*/
+const isRoomSearchProps = (props: any): props is { rid: string; token: string } => {
+ return 'rid' in props && 'token' in props;
+};
+
+const isRoomCreationProps = (props: any): props is { agentId: string; direction: IVoipRoom['direction'] } => {
+ return 'agentId' in props && 'direction' in props;
+};
+
API.v1.addRoute(
'voip/room',
{
authRequired: true,
rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 },
permissionsRequired: ['inbound-voip-calls'],
+ validateParams: isVoipRoomProps,
},
{
async get() {
- const defaultCheckParams = {
- token: String,
- agentId: Match.Maybe(String),
- rid: Match.Maybe(String),
- };
- check(this.queryParams, defaultCheckParams);
-
- const { token, rid, agentId } = this.queryParams;
+ const { token } = this.queryParams;
+ let agentId: string | undefined = undefined;
+ let direction: IVoipRoom['direction'] = 'inbound';
+ let rid: string | undefined = undefined;
+
+ if (isRoomCreationProps(this.queryParams)) {
+ agentId = this.queryParams.agentId;
+ direction = this.queryParams.direction;
+ }
+
+ if (isRoomSearchProps(this.queryParams)) {
+ rid = this.queryParams.rid;
+ }
+
const guest = await LivechatVisitors.getVisitorByToken(token, {});
if (!guest) {
return API.v1.failure('invalid-token');
@@ -123,7 +138,11 @@ API.v1.addRoute(
const agent = { agentId: _id, username };
const rid = Random.id();
- return API.v1.success(await LivechatVoip.getNewRoom(guest, agent, rid, { projection: API.v1.defaultFieldsToExclude }));
+ return API.v1.success(
+ await LivechatVoip.getNewRoom(guest, agent, rid, direction, {
+ projection: API.v1.defaultFieldsToExclude,
+ }),
+ );
}
const room = await VoipRoom.findOneByIdAndVisitorToken(rid, token, { projection: API.v1.defaultFieldsToExclude });
@@ -137,20 +156,15 @@ API.v1.addRoute(
API.v1.addRoute(
'voip/rooms',
- { authRequired: true },
+ { authRequired: true, validateParams: isVoipRoomsProps },
{
async get() {
const { offset, count } = this.getPaginationItems();
+
const { sort, fields } = this.parseJsonQuery();
- const { agents, open, tags, queue, visitorId } = this.requestParams();
+ const { agents, open, tags, queue, visitorId, direction, roomName } = this.requestParams();
const { createdAt: createdAtParam, closedAt: closedAtParam } = this.requestParams();
- check(agents, Match.Maybe([String]));
- check(open, Match.Maybe(String));
- check(tags, Match.Maybe([String]));
- check(queue, Match.Maybe(String));
- check(visitorId, Match.Maybe(String));
-
// Reusing same L room permissions for simplicity
const hasAdminAccess = hasPermission(this.userId, 'view-livechat-rooms');
const hasAgentAccess = hasPermission(this.userId, 'view-l-room') && agents?.includes(this.userId) && agents?.length === 1;
@@ -170,6 +184,8 @@ API.v1.addRoute(
visitorId,
createdAt,
closedAt,
+ direction,
+ roomName,
options: { sort, offset, count, fields },
}),
);
diff --git a/apps/meteor/client/providers/CallProvider/CallProvider.tsx b/apps/meteor/client/providers/CallProvider/CallProvider.tsx
index 00574b49faa..d9bc1d2c059 100644
--- a/apps/meteor/client/providers/CallProvider/CallProvider.tsx
+++ b/apps/meteor/client/providers/CallProvider/CallProvider.tsx
@@ -338,7 +338,7 @@ export const CallProvider: FC = ({ children }) => {
name: caller.callerName || caller.callerId,
},
});
- const voipRoom = visitor && (await voipEndpoint({ token: visitor.token, agentId: user._id }));
+ const voipRoom = visitor && (await voipEndpoint({ token: visitor.token, agentId: user._id, direction: 'inbound' }));
openRoom(voipRoom.room._id);
voipRoom.room && setRoomInfo({ v: { token: voipRoom.room.v.token }, rid: voipRoom.room._id });
const queueAggregator = voipClient.getAggregator();
diff --git a/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx b/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx
index db21eb25fe8..b0b443bf325 100644
--- a/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx
+++ b/apps/meteor/client/views/omnichannel/directory/calls/CallTable.tsx
@@ -1,3 +1,4 @@
+import { IVoipRoom } from '@rocket.chat/core-typings';
import { Table } from '@rocket.chat/fuselage';
import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useRoute, useTranslation } from '@rocket.chat/ui-contexts';
@@ -54,6 +55,17 @@ const CallTable: FC = () => {
const query = useQuery(debouncedParams, debouncedSort, userIdLoggedIn);
const directoryRoute = useRoute('omnichannel-directory');
+ const resolveDirectionLabel = useCallback(
+ (direction: IVoipRoom['direction']) => {
+ const labels = {
+ inbound: 'Incoming',
+ outbound: 'Outgoing',
+ } as const;
+ return t(labels[direction] || 'Not_Available');
+ },
+ [t],
+ );
+
const onHeaderClick = useMutableCallback((id) => {
const [sortBy, sortDirection] = sort;
@@ -117,21 +129,21 @@ const CallTable: FC = () => {
{t('Talk_Time')}
,
- {t('Source')}
+ {t('Direction')}
,
].filter(Boolean),
[sort, onHeaderClick, t],
);
const renderRow = useCallback(
- ({ _id, fname, callStarted, queue, callDuration, v }) => {
+ ({ _id, fname, callStarted, queue, callDuration, v, direction }) => {
const duration = moment.duration(callDuration / 1000, 'seconds');
return (
onRowClick(_id, v?.token)} action qa-user-id={_id}>
@@ -140,11 +152,11 @@ const CallTable: FC = () => {
{queue}
{moment(callStarted).format('L LTS')}
{duration.isValid() && duration.humanize()}
- {t('Incoming')}
+ {resolveDirectionLabel(direction)}
);
},
- [onRowClick, t],
+ [onRowClick, resolveDirectionLabel],
);
return (
diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
index 4042867a17f..2ab4cbd94ec 100644
--- a/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
+++ b/apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
@@ -1459,6 +1459,7 @@
"Details": "Details",
"Different_Style_For_User_Mentions": "Different style for user mentions",
"Direct": "Direct",
+ "Direction": "Direction",
"Direct_Message": "Direct Message",
"Direct_message_creation_description": "You are about to create a chat with multiple users. Add the ones you would like to talk, everyone in the same place, using direct messages.",
"Direct_message_someone": "Direct message someone",
diff --git a/apps/meteor/server/models/raw/VoipRoom.ts b/apps/meteor/server/models/raw/VoipRoom.ts
index c31e7872f8b..1955cc228e0 100644
--- a/apps/meteor/server/models/raw/VoipRoom.ts
+++ b/apps/meteor/server/models/raw/VoipRoom.ts
@@ -1,3 +1,4 @@
+import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { IRoomClosingInfo, IVoipRoom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IVoipRoomModel } from '@rocket.chat/model-typings';
import type { Collection, Cursor, Db, FilterQuery, FindOneOptions, WithoutProjection, WriteOpResult } from 'mongodb';
@@ -114,6 +115,8 @@ export class VoipRoomRaw extends BaseRaw implements IVoipRoomModel {
tags,
queue,
visitorId,
+ direction,
+ roomName,
options = {},
}: {
agents?: string[];
@@ -123,6 +126,8 @@ export class VoipRoomRaw extends BaseRaw implements IVoipRoomModel {
tags?: string[];
queue?: string;
visitorId?: string;
+ direction?: IVoipRoom['direction'];
+ roomName?: string;
options?: {
sort?: Record;
count?: number;
@@ -167,6 +172,12 @@ export class VoipRoomRaw extends BaseRaw implements IVoipRoomModel {
if (queue) {
query.queue = queue;
}
+ if (direction) {
+ query.direction = direction;
+ }
+ if (roomName) {
+ query.name = new RegExp(escapeRegExp(roomName), 'i');
+ }
return this.find(query, {
sort: options.sort || { name: 1 },
diff --git a/apps/meteor/server/sdk/types/IOmnichannelVoipService.ts b/apps/meteor/server/sdk/types/IOmnichannelVoipService.ts
index 3bfd3b95db9..367b3c44eb0 100644
--- a/apps/meteor/server/sdk/types/IOmnichannelVoipService.ts
+++ b/apps/meteor/server/sdk/types/IOmnichannelVoipService.ts
@@ -21,6 +21,7 @@ export interface IOmnichannelVoipService {
guest: ILivechatVisitor,
agent: { agentId: string; username: string },
rid: string,
+ direction: IVoipRoom['direction'],
options: FindOneOptions,
): Promise;
findRoom(token: string, rid: string): Promise;
diff --git a/apps/meteor/server/services/omnichannel-voip/internalTypes.ts b/apps/meteor/server/services/omnichannel-voip/internalTypes.ts
index e4fadd52c20..6625465ca08 100644
--- a/apps/meteor/server/services/omnichannel-voip/internalTypes.ts
+++ b/apps/meteor/server/services/omnichannel-voip/internalTypes.ts
@@ -1,4 +1,4 @@
-import { IMessage } from '@rocket.chat/core-typings';
+import { IVoipRoom, IMessage } from '@rocket.chat/core-typings';
export type FindVoipRoomsParams = {
agents?: string[];
@@ -14,6 +14,8 @@ export type FindVoipRoomsParams = {
fields?: Record;
offset?: number;
};
+ direction?: IVoipRoom['direction'];
+ roomName?: string;
};
export type IOmniRoomClosingMessage = Pick & Partial>;
diff --git a/apps/meteor/server/services/omnichannel-voip/service.ts b/apps/meteor/server/services/omnichannel-voip/service.ts
index 49609de942e..9e76bcc9bfe 100644
--- a/apps/meteor/server/services/omnichannel-voip/service.ts
+++ b/apps/meteor/server/services/omnichannel-voip/service.ts
@@ -97,6 +97,7 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
name: string,
agent: { agentId: string; username: string },
guest: ILivechatVisitor,
+ direction: IVoipRoom['direction'],
): Promise {
const status = 'online';
const { _id, department: departmentId } = guest;
@@ -161,6 +162,7 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
_id: agent.agentId,
username: agent.username,
},
+ direction,
_updatedAt: newRoomAt,
};
@@ -213,6 +215,7 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
guest: ILivechatVisitor,
agent: { agentId: string; username: string },
rid: string,
+ direction: IVoipRoom['direction'],
options: FindOneOptions = {},
): Promise {
this.logger.debug(`Attempting to find or create a room for visitor ${guest._id}`);
@@ -224,7 +227,7 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
}
if (room == null) {
const name = guest.name || guest.username;
- const roomId = await this.createVoipRoom(rid, name, agent, guest);
+ const roomId = await this.createVoipRoom(rid, name, agent, guest, direction);
room = await VoipRoom.findOneVoipRoomById(roomId);
newRoom = true;
this.logger.debug(`Room obtained for visitor ${guest._id} -> ${room?._id}`);
@@ -371,6 +374,8 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
visitorId,
tags,
queue,
+ direction,
+ roomName,
options: { offset = 0, count, fields, sort } = {},
}: FindVoipRoomsParams): Promise> {
const cursor = VoipRoom.findRoomsWithCriteria({
@@ -381,6 +386,8 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
tags,
queue,
visitorId,
+ direction,
+ roomName,
options: {
sort: sort || { ts: -1 },
offset,
diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts
index 15b77003134..e9a925e57f1 100644
--- a/apps/meteor/server/startup/migrations/index.ts
+++ b/apps/meteor/server/startup/migrations/index.ts
@@ -93,5 +93,6 @@ import './v266';
import './v267';
import './v268';
import './v269';
+import './v270';
import './v271';
import './xrun';
diff --git a/apps/meteor/server/startup/migrations/v270.ts b/apps/meteor/server/startup/migrations/v270.ts
new file mode 100644
index 00000000000..465b74add1d
--- /dev/null
+++ b/apps/meteor/server/startup/migrations/v270.ts
@@ -0,0 +1,21 @@
+import { VoipRoom } from '@rocket.chat/models';
+
+import { addMigration } from '../../lib/migrations';
+
+addMigration({
+ version: 270,
+ async up() {
+ // mark all voip rooms as inbound which doesn't have any direction property set or has an invalid value
+ await VoipRoom.updateMany(
+ {
+ t: 'v',
+ $or: [{ direction: { $exists: false } }, { direction: { $nin: ['inbound', 'outbound'] } }],
+ },
+ {
+ $set: {
+ direction: 'inbound',
+ },
+ },
+ );
+ },
+});
diff --git a/apps/meteor/tests/data/rooms.helper.js b/apps/meteor/tests/data/rooms.helper.js
index 34eb349ab08..78e07f11003 100644
--- a/apps/meteor/tests/data/rooms.helper.js
+++ b/apps/meteor/tests/data/rooms.helper.js
@@ -1,6 +1,6 @@
import { api, credentials, request } from './api-data';
-export const createRoom = ({ name, type, username, token, agentId, members, credentials: customCredentials }) => {
+export const createRoom = ({ name, type, username, token, agentId, members, credentials: customCredentials, voipCallDirection = 'inbound' }) => {
if (!type) {
throw new Error('"type" is required in "createRoom.ts" test helper');
}
@@ -11,7 +11,7 @@ export const createRoom = ({ name, type, username, token, agentId, members, cred
* is handled separately here.
*/
return request
- .get(api(`voip/room?token=${token}&agentId=${agentId}`))
+ .get(api(`voip/room?token=${token}&agentId=${agentId}&direction=${voipCallDirection}`))
.set(customCredentials || credentials)
.send();
}
diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts
index 920cca99d56..b374f9bfaa2 100644
--- a/packages/core-typings/src/IRoom.ts
+++ b/packages/core-typings/src/IRoom.ts
@@ -204,6 +204,8 @@ export interface IVoipRoom extends IOmnichannelGenericRoom {
status: 'online' | 'busy' | 'away' | 'offline';
phone?: string | null;
};
+ // Outbound means the call was initiated from Rocket.Chat and vise versa
+ direction: 'inbound' | 'outbound';
}
export interface IOmnichannelRoomFromAppSource extends IOmnichannelRoom {
diff --git a/packages/model-typings/src/models/IVoipRoomModel.ts b/packages/model-typings/src/models/IVoipRoomModel.ts
index be80c17f202..fb29633d6a7 100644
--- a/packages/model-typings/src/models/IVoipRoomModel.ts
+++ b/packages/model-typings/src/models/IVoipRoomModel.ts
@@ -23,6 +23,8 @@ export interface IVoipRoomModel extends IBaseModel {
tags,
queue,
visitorId,
+ direction,
+ roomName,
options,
}: {
agents?: string[];
@@ -32,6 +34,8 @@ export interface IVoipRoomModel extends IBaseModel {
tags?: string[];
queue?: string;
visitorId?: string;
+ direction?: IVoipRoom['direction'];
+ roomName?: string;
options?: {
sort?: Record;
count?: number;
diff --git a/packages/rest-typings/src/v1/voip.ts b/packages/rest-typings/src/v1/voip.ts
index b01f9dafbfd..56b7b02c6d3 100644
--- a/packages/rest-typings/src/v1/voip.ts
+++ b/packages/rest-typings/src/v1/voip.ts
@@ -320,7 +320,7 @@ const VoipEventsSchema: JSONSchemaType = {
export const isVoipEventsProps = ajv.compile(VoipEventsSchema);
-type VoipRoom = { token: string; agentId: ILivechatAgent['_id'] } | { rid: string; token: string };
+type VoipRoom = { token: string; agentId: ILivechatAgent['_id']; direction: IVoipRoom['direction'] } | { rid: string; token: string };
const VoipRoomSchema: JSONSchemaType = {
oneOf: [
@@ -333,6 +333,10 @@ const VoipRoomSchema: JSONSchemaType = {
agentId: {
type: 'string',
},
+ direction: {
+ type: 'string',
+ enum: ['inbound', 'outbound'],
+ },
},
required: ['token', 'agentId'],
additionalProperties: false,
@@ -405,7 +409,7 @@ const VoipCallServerCheckConnectionSchema: JSONSchemaType(VoipCallServerCheckConnectionSchema);
-type VoipRooms = {
+type VoipRooms = PaginatedRequest<{
agents?: string[];
open?: 'true' | 'false';
createdAt?: string;
@@ -413,7 +417,9 @@ type VoipRooms = {
tags?: string[];
queue?: string;
visitorId?: string;
-};
+ roomName?: string;
+ direction?: IVoipRoom['direction'];
+}>;
const VoipRoomsSchema: JSONSchemaType = {
type: 'object',
@@ -453,6 +459,31 @@ const VoipRoomsSchema: JSONSchemaType = {
type: 'string',
nullable: true,
},
+ direction: {
+ type: 'string',
+ enum: ['inbound', 'outbound'],
+ nullable: true,
+ },
+ roomName: {
+ type: 'string',
+ nullable: true,
+ },
+ count: {
+ type: 'number',
+ nullable: true,
+ },
+ offset: {
+ type: 'number',
+ nullable: true,
+ },
+ sort: {
+ type: 'string',
+ nullable: true,
+ },
+ query: {
+ type: 'string',
+ nullable: true,
+ },
},
required: [],
additionalProperties: false,