chore: add endpoints for retrieving call history data (#37702)

pull/37738/head
Pierre Lehnen 4 weeks ago committed by GitHub
parent 3837d205df
commit 9b3fa8d7b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      apps/meteor/app/api/server/index.ts
  2. 206
      apps/meteor/app/api/server/v1/call-history.ts
  3. 197
      apps/meteor/server/startup/callHistoryTestData.ts
  4. 6
      apps/meteor/server/startup/initialData.js
  5. 235
      apps/meteor/tests/end-to-end/api/call-history.ts
  6. 7
      packages/core-typings/src/Ajv.ts
  7. 15
      packages/model-typings/src/models/ICallHistoryModel.ts
  8. 18
      packages/models/src/models/CallHistory.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';

@ -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<Record<never, never>>;
const CallHistoryListSchema = {
type: 'object',
properties: {
count: {
type: 'number',
},
offset: {
type: 'number',
},
sort: {
type: 'string',
},
},
required: [],
additionalProperties: false,
};
export const isCallHistoryListProps = ajv.compile<CallHistoryList>(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<string, string | number | null | undefined>);
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<typeof callHistoryListEndpoints>;
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<CallHistoryInfo>(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<typeof callHistoryInfoEndpoints>;
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 {}
}

@ -0,0 +1,197 @@
import { CallHistory, MediaCalls } from '@rocket.chat/models';
export async function addCallHistoryTestData(uid: string, extraUid: string): Promise<void> {
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],
},
]);
}

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

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

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

@ -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<CallHistoryItem>;
export interface ICallHistoryModel extends IBaseModel<CallHistoryItem> {
findOneByIdAndUid(
_id: CallHistoryItem['_id'],
uid: CallHistoryItem['uid'],
options?: FindOptions<CallHistoryItem>,
): Promise<CallHistoryItem | null>;
findOneByCallIdAndUid(
callId: CallHistoryItem['callId'],
uid: CallHistoryItem['uid'],
options?: FindOptions<CallHistoryItem>,
): Promise<CallHistoryItem | null>;
}

@ -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<CallHistoryItem> 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<CallHistoryItem>,
): Promise<CallHistoryItem | null> {
return this.findOne({ _id, uid }, options);
}
async findOneByCallIdAndUid(
callId: CallHistoryItem['callId'],
uid: CallHistoryItem['uid'],
options?: FindOptions<CallHistoryItem>,
): Promise<CallHistoryItem | null> {
return this.findOne({ callId, uid }, options);
}
}

Loading…
Cancel
Save