From 3f2ff2664ca2b46f53b639f0621091e023d1f384 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:13:08 -0300 Subject: [PATCH] chore: add contact filter to call history endpoint (#37896) --- apps/meteor/app/api/server/v1/call-history.ts | 26 +- .../server/startup/callHistoryTestData.ts | 39 ++- .../tests/end-to-end/api/call-history.ts | 246 +++++++++++++++++- 3 files changed, 301 insertions(+), 10 deletions(-) diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts index 46f4dfd2666..17858dddf57 100644 --- a/apps/meteor/app/api/server/v1/call-history.ts +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -8,6 +8,7 @@ import { validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { ensureArray } from '../../../../lib/utils/arrayUtils'; import type { ExtractRoutesFromAPI } from '../ApiClass'; @@ -15,6 +16,7 @@ import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; type CallHistoryList = PaginatedRequest<{ + filter?: string; direction?: CallHistoryItem['direction']; state?: CallHistoryItemState[] | CallHistoryItemState; }>; @@ -31,6 +33,9 @@ const CallHistoryListSchema = { sort: { type: 'string', }, + filter: { + type: 'string', + }, direction: { type: 'string', enum: ['inbound', 'outbound'], @@ -106,14 +111,31 @@ const callHistoryListEndpoints = API.v1.get( const { offset, count } = await getPaginationItems(this.queryParams as Record); const { sort } = await this.parseJsonQuery(); - const { direction, state } = this.queryParams; + const { direction, state, filter } = this.queryParams; + + const filterText = typeof filter === 'string' && filter.trim(); const stateFilter = state && ensureArray(state); - const query = { uid: this.userId, ...(direction && { direction }), ...(stateFilter?.length && { state: { $in: stateFilter } }), + ...(filterText && { + $or: [ + { + external: false, + contactName: { $regex: escapeRegExp(filterText), $options: 'i' }, + }, + { + external: false, + contactUsername: { $regex: escapeRegExp(filterText), $options: 'i' }, + }, + { + external: true, + contactExtension: { $regex: escapeRegExp(filterText), $options: 'i' }, + }, + ], + }), }; const { cursor, totalCount } = CallHistory.findPaginated(query, { diff --git a/apps/meteor/server/startup/callHistoryTestData.ts b/apps/meteor/server/startup/callHistoryTestData.ts index 4bbea493459..2e18f74d959 100644 --- a/apps/meteor/server/startup/callHistoryTestData.ts +++ b/apps/meteor/server/startup/callHistoryTestData.ts @@ -6,6 +6,9 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro const callId3 = 'rocketchat.external.call.test.outbound'; const callId4 = 'rocketchat.external.call.test.inbound'; + const extraCallId1 = 'rocketchat.extra.call.test.1'; + const extraCallId2 = 'rocketchat.extra.call.test.2'; + await CallHistory.deleteMany({ uid }); await MediaCalls.deleteMany({ _id: { $in: [callId1, callId2, callId3, callId4] } }); @@ -22,6 +25,8 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro uid, contactId: extraUid, direction: 'outbound', + contactName: 'Pineapple', // random words used for searching + contactUsername: 'fruit-001', }, { _id: 'rocketchat.internal.history.test.inbound', @@ -35,6 +40,38 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro uid, contactId: extraUid, direction: 'inbound', + contactName: 'Apple', + contactUsername: 'fruit-002', + }, + { + _id: 'rocketchat.internal.history.test.outbound.2', + ts: new Date(), + callId: extraCallId1, + state: 'transferred', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'outbound', + contactName: 'Grapefruit 002', + contactUsername: 'username-001', + }, + { + _id: 'rocketchat.internal.history.test.inbound.2', + ts: new Date(), + callId: extraCallId2, + state: 'transferred', + type: 'media-call', + duration: 10, + endedAt: new Date(), + external: false, + uid, + contactId: extraUid, + direction: 'inbound', + contactName: 'Pasta 1', + contactUsername: 'meal', }, { _id: 'rocketchat.external.history.test.outbound', @@ -60,7 +97,7 @@ export async function addCallHistoryTestData(uid: string, extraUid: string): Pro external: true, uid, direction: 'inbound', - contactExtension: '1001', + contactExtension: '1002', }, ]); 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 3da7c4ceee2..ef2bdd06d72 100644 --- a/apps/meteor/tests/end-to-end/api/call-history.ts +++ b/apps/meteor/tests/end-to-end/api/call-history.ts @@ -32,15 +32,17 @@ describe('[Call History]', () => { 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); + expect(res.body.items).to.have.lengthOf(6); + expect(res.body).to.have.property('total', 6); + expect(res.body).to.have.property('count', 6); const historyIds = res.body.items.map((item: any) => item._id); 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'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound.2'); 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'); @@ -50,6 +52,8 @@ describe('[Call History]', () => { expect(internalItem1).to.have.property('external', false); expect(internalItem1).to.have.property('direction', 'outbound'); expect(internalItem1).to.have.property('contactId'); + expect(internalItem1).to.have.property('contactName', 'Pineapple'); + expect(internalItem1).to.have.property('contactUsername', 'fruit-001'); 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'); @@ -59,6 +63,20 @@ describe('[Call History]', () => { expect(internalItem2).to.have.property('external', false); expect(internalItem2).to.have.property('direction', 'inbound'); expect(internalItem2).to.have.property('contactId'); + expect(internalItem2).to.have.property('contactName', 'Apple'); + expect(internalItem2).to.have.property('contactUsername', 'fruit-002'); + + const internalItem3 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.outbound.2'); + expect(internalItem3).to.have.property('callId', 'rocketchat.extra.call.test.1'); + expect(internalItem3).to.have.property('state', 'transferred'); + expect(internalItem3).to.have.property('direction', 'outbound'); + expect(internalItem3).to.have.property('contactName', 'Grapefruit 002'); + + const internalItem4 = res.body.items.find((item: any) => item._id === 'rocketchat.internal.history.test.inbound.2'); + expect(internalItem4).to.have.property('callId', 'rocketchat.extra.call.test.2'); + expect(internalItem4).to.have.property('state', 'transferred'); + expect(internalItem4).to.have.property('direction', 'inbound'); + expect(internalItem4).to.have.property('contactName', 'Pasta 1'); 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'); @@ -76,7 +94,7 @@ describe('[Call History]', () => { 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'); + expect(externalItem2).to.have.property('contactExtension', '1002'); }); }); @@ -156,13 +174,14 @@ describe('[Call History]', () => { 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); + 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.inbound'); expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound.2'); }); }); @@ -188,6 +207,215 @@ describe('[Call History]', () => { expect(historyIds).to.include('rocketchat.external.history.test.inbound'); }); }); + + it('should return item that match full contact name', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'Pineapple', + }) + .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.internal.history.test.outbound'); + }); + }); + + it('should return items that match partial contact name', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'Apple', + }) + .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.internal.history.test.inbound'); + }); + }); + + it('should return item that match full contact username', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'fruit-001', + }) + .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.internal.history.test.outbound'); + }); + }); + + it('should return items that match partial contact username', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'fruit-', + }) + .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.internal.history.test.inbound'); + }); + }); + + it('should return items that match partial contact name or username', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: 'fruit', + }) + .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.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + }); + }); + + it('should return item that match full contact extension', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '1001', + }) + .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.outbound'); + }); + }); + + it('should return items that match partial contact extension', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '100', + }) + .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.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should return items that match partial contact name, username or extension', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '002', + }) + .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.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); + + it('should apply filter with falsy value', async () => { + await request + .get(api('call-history.list')) + .set(credentials) + .query({ + filter: '0', + }) + .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(5); + expect(res.body).to.have.property('total', 5); + expect(res.body).to.have.property('count', 5); + + const historyIds = res.body.items.map((item: any) => item._id); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.inbound'); + expect(historyIds).to.include('rocketchat.internal.history.test.outbound.2'); + expect(historyIds).to.include('rocketchat.external.history.test.outbound'); + expect(historyIds).to.include('rocketchat.external.history.test.inbound'); + }); + }); }); describe('[/call-history.info]', () => { @@ -214,6 +442,8 @@ describe('[Call History]', () => { 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('contactName', 'Pineapple'); + expect(item).to.have.property('contactUsername', 'fruit-001'); expect(item).to.have.property('ts'); expect(item).to.have.property('endedAt'); @@ -259,6 +489,8 @@ describe('[Call History]', () => { 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('contactName', 'Apple'); + expect(item).to.have.property('contactUsername', 'fruit-002'); expect(item).to.have.property('ts'); expect(item).to.have.property('endedAt');