From 9b3fa8d7b17a337052f36dc0e8ce81f765c0c7c5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:28:20 -0300 Subject: [PATCH] chore: add endpoints for retrieving call history data (#37702) --- apps/meteor/app/api/server/index.ts | 1 + apps/meteor/app/api/server/v1/call-history.ts | 206 +++++++++++++++ .../server/startup/callHistoryTestData.ts | 197 +++++++++++++++ apps/meteor/server/startup/initialData.js | 6 +- .../tests/end-to-end/api/call-history.ts | 235 ++++++++++++++++++ packages/core-typings/src/Ajv.ts | 7 +- .../src/models/ICallHistoryModel.ts | 15 +- packages/models/src/models/CallHistory.ts | 18 +- 8 files changed, 681 insertions(+), 4 deletions(-) create mode 100644 apps/meteor/app/api/server/v1/call-history.ts create mode 100644 apps/meteor/server/startup/callHistoryTestData.ts create mode 100644 apps/meteor/tests/end-to-end/api/call-history.ts diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 971c0476ad5..1d4e9ac9fd7 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -9,6 +9,7 @@ import './helpers/parseJsonQuery'; import './default/info'; import './v1/assets'; import './v1/calendar'; +import './v1/call-history'; import './v1/channels'; import './v1/chat'; import './v1/cloud'; diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts new file mode 100644 index 00000000000..aaa0cab8c4a --- /dev/null +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -0,0 +1,206 @@ +import type { CallHistoryItem, IMediaCall } from '@rocket.chat/core-typings'; +import { CallHistory, MediaCalls } from '@rocket.chat/models'; +import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; +import { + ajv, + validateNotFoundErrorResponse, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, +} from '@rocket.chat/rest-typings'; + +import type { ExtractRoutesFromAPI } from '../ApiClass'; +import { API } from '../api'; +import { getPaginationItems } from '../helpers/getPaginationItems'; + +type CallHistoryList = PaginatedRequest>; + +const CallHistoryListSchema = { + type: 'object', + properties: { + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + sort: { + type: 'string', + }, + }, + required: [], + additionalProperties: false, +}; + +export const isCallHistoryListProps = ajv.compile(CallHistoryListSchema); + +const callHistoryListEndpoints = API.v1.get( + 'call-history.list', + { + response: { + 200: ajv.compile< + PaginatedResult<{ + items: CallHistoryItem[]; + }> + >({ + additionalProperties: false, + type: 'object', + properties: { + count: { + type: 'number', + description: 'The number of history items returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of history items that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of history items that match the query.', + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + items: { + type: 'array', + items: { + $ref: '#/components/schemas/CallHistoryItem', + }, + }, + }, + required: ['count', 'offset', 'total', 'items', 'success'], + }), + 400: validateBadRequestErrorResponse, + 403: validateUnauthorizedErrorResponse, + }, + query: isCallHistoryListProps, + authRequired: true, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort } = await this.parseJsonQuery(); + + const filter = { + uid: this.userId, + }; + + const { cursor, totalCount } = CallHistory.findPaginated(filter, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }); + const [items, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + items, + count: items.length, + offset, + total, + }); + }, +); + +type CallHistoryListEndpoints = ExtractRoutesFromAPI; + +type CallHistoryInfo = { historyId: string; callId: never } | { callId: string; historyId: never }; + +const CallHistoryInfoSchema = { + oneOf: [ + { + type: 'object', + properties: { + historyId: { + type: 'string', + nullable: false, + }, + }, + required: ['historyId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + }, + required: ['callId'], + additionalProperties: false, + }, + ], +}; + +export const isCallHistoryInfoProps = ajv.compile(CallHistoryInfoSchema); + +const callHistoryInfoEndpoints = API.v1.get( + 'call-history.info', + { + response: { + 200: ajv.compile<{ + item: CallHistoryItem; + call?: IMediaCall; + }>({ + additionalProperties: false, + type: 'object', + properties: { + item: { + $ref: '#/components/schemas/CallHistoryItem', + description: 'The requested call history item.', + }, + call: { + type: 'object', + $ref: '#/components/schemas/IMediaCall', + description: 'The call information for the requested call history item.', + nullable: true, + }, + success: { + type: 'boolean', + description: 'Indicates if the request was successful.', + }, + }, + required: ['item', 'success'], + }), + 400: validateBadRequestErrorResponse, + 403: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + query: isCallHistoryInfoProps, + authRequired: true, + }, + async function action() { + if (!this.queryParams.historyId && !this.queryParams.callId) { + return API.v1.failure(); + } + + const item = await (this.queryParams.historyId + ? CallHistory.findOneByIdAndUid(this.queryParams.historyId, this.userId) + : CallHistory.findOneByCallIdAndUid(this.queryParams.callId, this.userId)); + + if (!item) { + return API.v1.notFound(); + } + + if (item.type === 'media-call' && item.callId) { + const call = await MediaCalls.findOneById(item.callId); + if (call) { + return API.v1.success({ + item, + call, + }); + } + } + + return API.v1.success({ item }); + }, +); + +type CallHistoryInfoEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CallHistoryListEndpoints {} + + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CallHistoryInfoEndpoints {} +} diff --git a/apps/meteor/server/startup/callHistoryTestData.ts b/apps/meteor/server/startup/callHistoryTestData.ts new file mode 100644 index 00000000000..c776b21be31 --- /dev/null +++ b/apps/meteor/server/startup/callHistoryTestData.ts @@ -0,0 +1,197 @@ +import { CallHistory, MediaCalls } from '@rocket.chat/models'; + +export async function addCallHistoryTestData(uid: string, extraUid: string): Promise { + const callId1 = 'rocketchat.internal.call.test'; + const callId2 = 'rocketchat.internal.call.test.2'; + const callId3 = 'rocketchat.internal.call.test.3'; + const callId4 = 'rocketchat.internal.call.test.4'; + + await CallHistory.deleteMany({ uid }); + await MediaCalls.deleteMany({ _id: { $in: [callId1, callId2, callId3, callId4] } }); + + await CallHistory.insertMany([ + { + _id: 'rocketchat.internal.history.test', + ts: new Date(), + callId: callId1, + state: 'ended', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'outbound', + }, + { + _id: 'rocketchat.internal.history.test.2', + ts: new Date(), + callId: callId2, + state: 'ended', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'inbound', + }, + { + _id: 'rocketchat.internal.history.test.3', + ts: new Date(), + callId: callId3, + state: 'ended', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: true, + uid, + direction: 'outbound', + contactExtension: '1001', + }, + { + _id: 'rocketchat.internal.history.test.4', + ts: new Date(), + callId: callId4, + state: 'ended', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: true, + uid, + direction: 'inbound', + contactExtension: '1001', + }, + ]); + + await MediaCalls.insertMany([ + { + _id: callId1, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: uid, + }, + createdAt: new Date(), + caller: { + type: 'user', + id: uid, + contractId: 'contract1', + }, + callee: { + type: 'user', + id: extraUid, + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid, extraUid], + }, + { + _id: callId2, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: extraUid, + }, + createdAt: new Date(), + caller: { + type: 'user', + id: extraUid, + contractId: 'contract1', + }, + callee: { + type: 'user', + id: uid, + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid, extraUid], + }, + { + _id: callId3, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'user', + id: uid, + }, + createdAt: new Date(), + caller: { + type: 'user', + id: uid, + contractId: 'contract1', + }, + callee: { + type: 'sip', + id: '1001', + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid], + }, + { + _id: callId4, + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { + type: 'sip', + id: '1001', + }, + createdAt: new Date(), + caller: { + type: 'sip', + id: '1001', + contractId: 'contract1', + }, + callee: { + type: 'user', + id: uid, + contractId: 'contract2', + }, + ended: true, + endedBy: { + type: 'user', + id: uid, + }, + endedAt: new Date(), + hangupReason: 'normal', + expiresAt: new Date(), + acceptedAt: new Date(), + activatedAt: new Date(), + uids: [uid], + }, + ]); +} diff --git a/apps/meteor/server/startup/initialData.js b/apps/meteor/server/startup/initialData.js index 5de4aad42aa..e8e4873310e 100644 --- a/apps/meteor/server/startup/initialData.js +++ b/apps/meteor/server/startup/initialData.js @@ -3,6 +3,7 @@ import colors from 'colors/safe'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; +import { addCallHistoryTestData } from './callHistoryTestData'; import { RocketChatFile } from '../../app/file/server'; import { FileUpload } from '../../app/file-upload/server'; import { addUserToDefaultChannels } from '../../app/lib/server/functions/addUserToDefaultChannels'; @@ -251,6 +252,9 @@ Meteor.startup(async () => { void notifyOnSettingChangedById('Show_Setup_Wizard'); } - return addUserToDefaultChannels(adminUser, true); + await addUserToDefaultChannels(adminUser, true); + + // Create sample call history for API tests + return addCallHistoryTestData('rocketchat.internal.admin.test', 'rocket.cat'); } }); diff --git a/apps/meteor/tests/end-to-end/api/call-history.ts b/apps/meteor/tests/end-to-end/api/call-history.ts new file mode 100644 index 00000000000..c0ee2ab167d --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/call-history.ts @@ -0,0 +1,235 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +describe('[Call History]', () => { + let user2: IUser; + let userCredentials: Credentials; + + before((done) => getCredentials(done)); + + before(async () => { + user2 = await createUser(); + userCredentials = await login(user2.username, password); + }); + + after(() => deleteUser(user2)); + + describe('[/call-history.list]', () => { + it('should list all history entries for that uid', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(4); + expect(res.body).to.have.property('total', 4); + expect(res.body).to.have.property('count', 4); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test'); + expect(historyIds).to.include('rocketchat.internal.history.test.2'); + expect(historyIds).to.include('rocketchat.internal.history.test.3'); + expect(historyIds).to.include('rocketchat.internal.history.test.4'); + + const internalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test'); + expect(internalItem1).to.have.property('callId', 'rocketchat.internal.call.test'); + expect(internalItem1).to.have.property('state', 'ended'); + expect(internalItem1).to.have.property('type', 'media-call'); + expect(internalItem1).to.have.property('duration', 10); + expect(internalItem1).to.have.property('external', false); + expect(internalItem1).to.have.property('direction', 'outbound'); + expect(internalItem1).to.have.property('contactId'); + + const internalItem2 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.2'); + expect(internalItem2).to.have.property('callId', 'rocketchat.internal.call.test.2'); + expect(internalItem2).to.have.property('state', 'ended'); + expect(internalItem2).to.have.property('type', 'media-call'); + expect(internalItem2).to.have.property('duration', 10); + expect(internalItem2).to.have.property('external', false); + expect(internalItem2).to.have.property('direction', 'inbound'); + expect(internalItem2).to.have.property('contactId'); + + const externalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.3'); + expect(externalItem1).to.have.property('callId', 'rocketchat.internal.call.test.3'); + expect(externalItem1).to.have.property('state', 'ended'); + expect(externalItem1).to.have.property('type', 'media-call'); + expect(externalItem1).to.have.property('duration', 10); + expect(externalItem1).to.have.property('external', true); + expect(externalItem1).to.have.property('direction', 'outbound'); + expect(externalItem1).to.have.property('contactExtension', '1001'); + + const externalItem2 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.4'); + expect(externalItem2).to.have.property('callId', 'rocketchat.internal.call.test.4'); + expect(externalItem2).to.have.property('state', 'ended'); + expect(externalItem2).to.have.property('type', 'media-call'); + expect(externalItem2).to.have.property('duration', 10); + expect(externalItem2).to.have.property('external', true); + expect(externalItem2).to.have.property('direction', 'inbound'); + expect(externalItem2).to.have.property('contactExtension', '1001'); + }); + }); + + it('should not list history entries from other users', async () => { + await request + .get(api('call-history.list')) + .set(userCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('items').that.is.an('array'); + + expect(res.body.items).to.have.lengthOf(0); + expect(res.body).to.have.property('total', 0); + expect(res.body).to.have.property('count', 0); + }); + }); + }); + + describe('[/call-history.info]', () => { + it('should return the history entry information', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + historyId: 'rocketchat.internal.history.test', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('item').that.is.an('object'); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { item, call } = res.body; + expect(item).to.have.property('_id', 'rocketchat.internal.history.test'); + expect(item).to.have.property('callId', 'rocketchat.internal.call.test'); + expect(item).to.have.property('state', 'ended'); + expect(item).to.have.property('type', 'media-call'); + expect(item).to.have.property('duration', 10); + expect(item).to.have.property('external', false); + expect(item).to.have.property('direction', 'outbound'); + expect(item).to.have.property('contactId'); + expect(item).to.have.property('ts'); + expect(item).to.have.property('endedAt'); + + expect(call).to.have.property('_id', 'rocketchat.internal.call.test'); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'user'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(2); + }); + }); + + it('should return the history entry information when searching by call id', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + callId: 'rocketchat.internal.call.test.2', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('item').that.is.an('object'); + expect(res.body).to.have.property('call').that.is.an('object'); + + const { item, call } = res.body; + expect(item).to.have.property('_id', 'rocketchat.internal.history.test.2'); + expect(item).to.have.property('callId', 'rocketchat.internal.call.test.2'); + expect(item).to.have.property('state', 'ended'); + expect(item).to.have.property('type', 'media-call'); + expect(item).to.have.property('duration', 10); + expect(item).to.have.property('external', false); + expect(item).to.have.property('direction', 'inbound'); + expect(item).to.have.property('contactId'); + expect(item).to.have.property('ts'); + expect(item).to.have.property('endedAt'); + + expect(call).to.have.property('_id', 'rocketchat.internal.call.test.2'); + expect(call).to.have.property('service', 'webrtc'); + expect(call).to.have.property('kind', 'direct'); + expect(call).to.have.property('state', 'hangup'); + expect(call).to.have.property('ended', true); + expect(call).to.have.property('hangupReason', 'normal'); + expect(call).to.have.property('createdBy').that.is.an('object'); + expect(call.createdBy).to.have.property('type', 'user'); + expect(call).to.have.property('caller').that.is.an('object'); + expect(call.caller).to.have.property('type', 'user'); + expect(call).to.have.property('callee').that.is.an('object'); + expect(call.callee).to.have.property('type', 'user'); + expect(call).to.have.property('endedBy').that.is.an('object'); + expect(call.endedBy).to.have.property('type', 'user'); + expect(call).to.have.property('uids').that.is.an('array'); + expect(call.uids).to.have.lengthOf(2); + }); + }); + + it('should fail when querying an invalid entry', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + historyId: 'something-random', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should fail when querying an invalid entry by call id', async () => { + await request + .get(api('call-history.info')) + .set(credentials) + .query({ + callId: 'something-random', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should fail when querying an entry from another user', async () => { + await request + .get(api('call-history.info')) + .set(userCredentials) + .query({ + historyId: 'rocketchat.internal.history.test', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + + it('should fail when querying an entry from another user by call id', async () => { + await request + .get(api('call-history.info')) + .set(userCredentials) + .query({ + callId: 'rocketchat.internal.call.test.2', + }) + .expect('Content-Type', 'application/json') + .expect(404); + }); + }); +}); diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index 8d828e041b1..ef74c7638a3 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -1,10 +1,15 @@ import typia from 'typia'; +import type { CallHistoryItem } from './ICallHistoryItem'; import type { ICustomSound } from './ICustomSound'; import type { IInvite } from './IInvite'; import type { IMessage } from './IMessage'; import type { IOAuthApps } from './IOAuthApps'; import type { IPermission } from './IPermission'; import type { ISubscription } from './ISubscription'; +import type { IMediaCall } from './mediaCalls/IMediaCall'; -export const schemas = typia.json.schemas<[ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission], '3.0'>(); +export const schemas = typia.json.schemas< + [ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall, CallHistoryItem], + '3.0' +>(); diff --git a/packages/model-typings/src/models/ICallHistoryModel.ts b/packages/model-typings/src/models/ICallHistoryModel.ts index b700a5054ba..bbb7503a1a1 100644 --- a/packages/model-typings/src/models/ICallHistoryModel.ts +++ b/packages/model-typings/src/models/ICallHistoryModel.ts @@ -1,5 +1,18 @@ import type { CallHistoryItem } from '@rocket.chat/core-typings'; +import type { FindOptions } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; -export type ICallHistoryModel = IBaseModel; +export interface ICallHistoryModel extends IBaseModel { + findOneByIdAndUid( + _id: CallHistoryItem['_id'], + uid: CallHistoryItem['uid'], + options?: FindOptions, + ): Promise; + + findOneByCallIdAndUid( + callId: CallHistoryItem['callId'], + uid: CallHistoryItem['uid'], + options?: FindOptions, + ): Promise; +} diff --git a/packages/models/src/models/CallHistory.ts b/packages/models/src/models/CallHistory.ts index 960b069e544..73c67714ef6 100644 --- a/packages/models/src/models/CallHistory.ts +++ b/packages/models/src/models/CallHistory.ts @@ -1,6 +1,6 @@ import type { CallHistoryItem } from '@rocket.chat/core-typings'; import type { ICallHistoryModel } from '@rocket.chat/model-typings'; -import type { Db, IndexDescription } from 'mongodb'; +import type { Db, FindOptions, IndexDescription } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -12,4 +12,20 @@ export class CallHistoryRaw extends BaseRaw implements ICallHis protected override modelIndexes(): IndexDescription[] { return [{ key: { uid: 1, callId: 1 }, unique: true }, { key: { uid: 1, ts: -1 } }]; } + + async findOneByIdAndUid( + _id: CallHistoryItem['_id'], + uid: CallHistoryItem['uid'], + options?: FindOptions, + ): Promise { + return this.findOne({ _id, uid }, options); + } + + async findOneByCallIdAndUid( + callId: CallHistoryItem['callId'], + uid: CallHistoryItem['uid'], + options?: FindOptions, + ): Promise { + return this.findOne({ callId, uid }, options); + } }