diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts index aaa0cab8c4a..46f4dfd2666 100644 --- a/apps/meteor/app/api/server/v1/call-history.ts +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -1,4 +1,4 @@ -import type { CallHistoryItem, IMediaCall } from '@rocket.chat/core-typings'; +import type { CallHistoryItem, CallHistoryItemState, IMediaCall } from '@rocket.chat/core-typings'; import { CallHistory, MediaCalls } from '@rocket.chat/models'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; import { @@ -6,13 +6,18 @@ import { validateNotFoundErrorResponse, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; +import { ensureArray } from '../../../../lib/utils/arrayUtils'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -type CallHistoryList = PaginatedRequest>; +type CallHistoryList = PaginatedRequest<{ + direction?: CallHistoryItem['direction']; + state?: CallHistoryItemState[] | CallHistoryItemState; +}>; const CallHistoryListSchema = { type: 'object', @@ -26,6 +31,26 @@ const CallHistoryListSchema = { sort: { type: 'string', }, + direction: { + type: 'string', + enum: ['inbound', 'outbound'], + }, + state: { + // our clients serialize arrays as `state=value1&state=value2`, but if there's a single value the parser doesn't know it is an array, so we need to support both arrays and direct values + // if a client tries to send a JSON array, our parser will treat it as a string and the type validation will reject it + // This means this param won't work from Swagger UI + oneOf: [ + { + type: 'array', + items: { + $ref: '#/components/schemas/CallHistoryItemState', + }, + }, + { + $ref: '#/components/schemas/CallHistoryItemState', + }, + ], + }, }, required: [], additionalProperties: false, @@ -71,7 +96,8 @@ const callHistoryListEndpoints = API.v1.get( required: ['count', 'offset', 'total', 'items', 'success'], }), 400: validateBadRequestErrorResponse, - 403: validateUnauthorizedErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, query: isCallHistoryListProps, authRequired: true, @@ -80,11 +106,17 @@ const callHistoryListEndpoints = API.v1.get( const { offset, count } = await getPaginationItems(this.queryParams as Record); const { sort } = await this.parseJsonQuery(); - const filter = { + const { direction, state } = this.queryParams; + + const stateFilter = state && ensureArray(state); + + const query = { uid: this.userId, + ...(direction && { direction }), + ...(stateFilter?.length && { state: { $in: stateFilter } }), }; - const { cursor, totalCount } = CallHistory.findPaginated(filter, { + const { cursor, totalCount } = CallHistory.findPaginated(query, { sort: sort || { ts: -1 }, skip: offset, limit: count, @@ -162,7 +194,8 @@ const callHistoryInfoEndpoints = API.v1.get( required: ['item', 'success'], }), 400: validateBadRequestErrorResponse, - 403: validateUnauthorizedErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, 404: validateNotFoundErrorResponse, }, query: isCallHistoryInfoProps, diff --git a/apps/meteor/server/startup/callHistoryTestData.ts b/apps/meteor/server/startup/callHistoryTestData.ts index c776b21be31..4bbea493459 100644 --- a/apps/meteor/server/startup/callHistoryTestData.ts +++ b/apps/meteor/server/startup/callHistoryTestData.ts @@ -3,15 +3,15 @@ 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'; + const callId3 = 'rocketchat.external.call.test.outbound'; + const callId4 = 'rocketchat.external.call.test.inbound'; await CallHistory.deleteMany({ uid }); await MediaCalls.deleteMany({ _id: { $in: [callId1, callId2, callId3, callId4] } }); await CallHistory.insertMany([ { - _id: 'rocketchat.internal.history.test', + _id: 'rocketchat.internal.history.test.outbound', ts: new Date(), callId: callId1, state: 'ended', @@ -24,10 +24,10 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro direction: 'outbound', }, { - _id: 'rocketchat.internal.history.test.2', + _id: 'rocketchat.internal.history.test.inbound', ts: new Date(), callId: callId2, - state: 'ended', + state: 'not-answered', type: 'media-call', duration: 10, endedAt: new Date(), @@ -37,10 +37,10 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro direction: 'inbound', }, { - _id: 'rocketchat.internal.history.test.3', + _id: 'rocketchat.external.history.test.outbound', ts: new Date(), callId: callId3, - state: 'ended', + state: 'failed', type: 'media-call', duration: 10, endedAt: new Date(), @@ -50,7 +50,7 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro contactExtension: '1001', }, { - _id: 'rocketchat.internal.history.test.4', + _id: 'rocketchat.external.history.test.inbound', ts: new Date(), callId: callId4, state: 'ended', diff --git a/apps/meteor/tests/end-to-end/api/call-history.ts b/apps/meteor/tests/end-to-end/api/call-history.ts index c0ee2ab167d..3da7c4ceee2 100644 --- a/apps/meteor/tests/end-to-end/api/call-history.ts +++ b/apps/meteor/tests/end-to-end/api/call-history.ts @@ -37,12 +37,12 @@ describe('[Call History]', () => { 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'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); - const internalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test'); + const internalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.outbound'); 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'); @@ -51,26 +51,26 @@ describe('[Call History]', () => { 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'); + const internalItem2 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.inbound'); expect(internalItem2).to.have.property('callId', 'rocketchat.internal.call.test.2'); - expect(internalItem2).to.have.property('state', 'ended'); + expect(internalItem2).to.have.property('state', 'not-answered'); 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'); + const externalItem1 = res.body.items.find((item: any) => item._id === 'rocketchat.external.history.test.outbound'); + expect(externalItem1).to.have.property('callId', 'rocketchat.external.call.test.outbound'); + expect(externalItem1).to.have.property('state', 'failed'); 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'); + const externalItem2 = res.body.items.find((item: any) => item._id === 'rocketchat.external.history.test.inbound'); + expect(externalItem2).to.have.property('callId', 'rocketchat.external.call.test.inbound'); expect(externalItem2).to.have.property('state', 'ended'); expect(externalItem2).to.have.property('type', 'media-call'); expect(externalItem2).to.have.property('duration', 10); @@ -95,6 +95,99 @@ describe('[Call History]', () => { expect(res.body).to.have.property('count', 0); }); }); + + it('should apply filter by state', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + state: ['ended'], + }) + .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(2); + expect(res.body).to.have.property('total', 2); + expect(res.body).to.have.property('count', 2); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter by multiple states', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + state: ['failed', 'ended'], + }) + .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(3); + expect(res.body).to.have.property('total', 3); + expect(res.body).to.have.property('count', 3); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter by direction', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + direction: 'inbound', + }) + .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(2); + expect(res.body).to.have.property('total', 2); + expect(res.body).to.have.property('count', 2); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter by state and direction', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + state: ['failed', 'ended'], + direction: 'inbound', + }) + .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(1); + expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('count', 1); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); }); describe('[/call-history.info]', () => { @@ -103,7 +196,7 @@ describe('[Call History]', () => { .get(api('call-history.info')) .set(credentials) .query({ - historyId: 'rocketchat.internal.history.test', + historyId: 'rocketchat.internal.history.test.outbound', }) .expect('Content-Type', 'application/json') .expect(200) @@ -113,7 +206,7 @@ describe('[Call History]', () => { 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('_id', 'rocketchat.internal.history.test.outbound'); 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'); @@ -158,9 +251,9 @@ describe('[Call History]', () => { 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('_id', 'rocketchat.internal.history.test.inbound'); expect(item).to.have.property('callId', 'rocketchat.internal.call.test.2'); - expect(item).to.have.property('state', 'ended'); + expect(item).to.have.property('state', 'not-answered'); expect(item).to.have.property('type', 'media-call'); expect(item).to.have.property('duration', 10); expect(item).to.have.property('external', false); @@ -215,7 +308,7 @@ describe('[Call History]', () => { .get(api('call-history.info')) .set(userCredentials) .query({ - historyId: 'rocketchat.internal.history.test', + historyId: 'rocketchat.internal.history.test.outbound', }) .expect('Content-Type', 'application/json') .expect(404);