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 ![image](https://user-images.githubusercontent.com/34130764/170512008-34202ed8-3ed4-4c28-baa5-25efc17543d5.png) ## 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,