diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index ec0559d5f191..0de3181708fb 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -3,13 +3,15 @@ import { isPOSTOmnichannelContactsProps, isPOSTUpdateOmnichannelContactsProps, isGETOmnichannelContactsProps, + isGETOmnichannelContactsSearchProps, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Contacts, createContact, updateContact, isSingleContactEnabled } from '../../lib/Contacts'; +import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems'; +import { Contacts, createContact, updateContact, getContacts, isSingleContactEnabled } from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', @@ -50,7 +52,10 @@ API.v1.addRoute( API.v1.addRoute( 'omnichannel/contact.search', - { authRequired: true, permissionsRequired: ['view-l-room'] }, + { + authRequired: true, + permissionsRequired: ['view-l-room'], + }, { async get() { check(this.queryParams, { @@ -136,3 +141,23 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'omnichannel/contacts.search', + { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsSearchProps }, + { + async get() { + if (!isSingleContactEnabled()) { + return API.v1.unauthorized(); + } + + const { searchText } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const result = await getContacts({ searchText, offset, count, sort }); + + return API.v1.success(result); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index e9be40aa942b..8d440e297249 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -17,9 +17,10 @@ import { Subscriptions, LivechatContacts, } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; +import type { MatchKeysAndValues, OnlyFieldsOfType, Sort } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { trim } from '../../../../lib/utils/stringUtils'; @@ -63,6 +64,13 @@ type UpdateContactParams = { channels?: ILivechatContactChannel[]; }; +type GetContactsParams = { + searchText?: string; + count: number; + offset: number; + sort: Sort; +}; + export const Contacts = { async registerContact({ token, @@ -225,6 +233,25 @@ export async function updateContact(params: UpdateContactParams): Promise> { + const { searchText, count, offset, sort } = params; + + const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(searchText, { + limit: count, + skip: offset, + sort: sort ?? { name: 1 }, + }); + + const [contacts, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + contacts, + count, + offset, + total, + }; +} + async function getAllowedCustomFields(): Promise[]> { return LivechatCustomField.findByScope( 'visitor', diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index 88dac1b9f5c1..1036205d1539 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -1,6 +1,7 @@ import type { ILivechatContact, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; -import type { ILivechatContactsModel } from '@rocket.chat/model-typings'; -import type { Collection, Db } from 'mongodb'; +import type { FindPaginated, ILivechatContactsModel } from '@rocket.chat/model-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { Collection, Db, RootFilterOperators, Filter, FindOptions, FindCursor, IndexDescription } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -9,6 +10,29 @@ export class LivechatContactsRaw extends BaseRaw implements IL super(db, 'livechat_contact', trash); } + protected modelIndexes(): IndexDescription[] { + return [ + { + key: { name: 1 }, + unique: false, + name: 'name_insensitive', + collation: { locale: 'en', strength: 2, caseLevel: false }, + }, + { + key: { emails: 1 }, + unique: false, + name: 'emails_insensitive', + partialFilterExpression: { emails: { $exists: true } }, + collation: { locale: 'en', strength: 2, caseLevel: false }, + }, + { + key: { phones: 1 }, + partialFilterExpression: { phones: { $exists: true } }, + unique: false, + }, + ]; + } + async updateContact(contactId: string, data: Partial): Promise { const updatedValue = await this.findOneAndUpdate( { _id: contactId }, @@ -17,4 +41,23 @@ export class LivechatContactsRaw extends BaseRaw implements IL ); return updatedValue.value as ILivechatContact; } + + findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated> { + const searchRegex = escapeRegExp(searchText || ''); + const match: Filter> = { + $or: [ + { name: { $regex: searchRegex, $options: 'i' } }, + { emails: { $regex: searchRegex, $options: 'i' } }, + { phones: { $regex: searchRegex, $options: 'i' } }, + ], + }; + + return this.findPaginated( + { ...match }, + { + allowDiskUse: true, + ...options, + }, + ); + } } diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index c33ef255c25c..cfbae3db4872 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -632,4 +632,98 @@ describe('LIVECHAT - contacts', () => { expect(res.body.errorType).to.be.equal('invalid-params'); }); }); + + describe('[GET] omnichannel/contacts.search', () => { + let contactId: string; + const contact = { + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: agentUser?._id, + }; + + before(async () => { + await updatePermission('view-livechat-contact', ['admin']); + const { body } = await request.post(api('omnichannel/contacts')).set(credentials).send(contact); + contactId = body.contactId; + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + }); + + it('should be able to list all contacts', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.greaterThan(0); + expect(res.body.count).to.be.an('number'); + expect(res.body.total).to.be.an('number'); + expect(res.body.offset).to.be.an('number'); + }); + + it('should return only contacts that match the searchText using email', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: contact.emails[0] }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.contacts[0]._id).to.be.equal(contactId); + expect(res.body.contacts[0].name).to.be.equal(contact.name); + expect(res.body.contacts[0].emails[0]).to.be.equal(contact.emails[0]); + }); + + it('should return only contacts that match the searchText using phone number', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: contact.phones[0] }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.contacts[0]._id).to.be.equal(contactId); + expect(res.body.contacts[0].name).to.be.equal(contact.name); + expect(res.body.contacts[0].emails[0]).to.be.equal(contact.emails[0]); + }); + + it('should return only contacts that match the searchText using name', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: contact.name }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(1); + expect(res.body.total).to.be.equal(1); + expect(res.body.contacts[0]._id).to.be.equal(contactId); + expect(res.body.contacts[0].name).to.be.equal(contact.name); + expect(res.body.contacts[0].emails[0]).to.be.equal(contact.emails[0]); + }); + + it('should return an empty list if no contacts exist', async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials).query({ searchText: 'invalid' }); + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contacts).to.be.an('array'); + expect(res.body.contacts.length).to.be.equal(0); + expect(res.body.total).to.be.equal(0); + }); + + describe('Permissions', () => { + before(async () => { + await removePermissionFromAllRoles('view-livechat-contact'); + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + }); + + it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => { + const res = await request.get(api(`omnichannel/contacts.search`)).set(credentials); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); }); diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index f94216830884..9f7bc23e7c2c 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -1,7 +1,9 @@ import type { ILivechatContact } from '@rocket.chat/core-typings'; +import type { FindCursor, FindOptions } from 'mongodb'; -import type { IBaseModel } from './IBaseModel'; +import type { FindPaginated, IBaseModel } from './IBaseModel'; export interface ILivechatContactsModel extends IBaseModel { updateContact(contactId: string, data: Partial): Promise; + findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated>; } diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index b494e5d0e5a9..09c021a4e7d9 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1329,6 +1329,32 @@ const GETOmnichannelContactsSchema = { export const isGETOmnichannelContactsProps = ajv.compile(GETOmnichannelContactsSchema); +type GETOmnichannelContactsSearchProps = PaginatedRequest<{ + searchText: string; +}>; + +const GETOmnichannelContactsSearchSchema = { + type: 'object', + properties: { + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + sort: { + type: 'string', + }, + searchText: { + type: 'string', + }, + }, + required: [], + additionalProperties: false, +}; + +export const isGETOmnichannelContactsSearchProps = ajv.compile(GETOmnichannelContactsSearchSchema); + type GETOmnichannelContactProps = { contactId: string }; const GETOmnichannelContactSchema = { @@ -3776,6 +3802,9 @@ export type OmnichannelEndpoints = { '/v1/omnichannel/contacts.get': { GET: (params: GETOmnichannelContactsProps) => { contact: ILivechatContact | null }; }; + '/v1/omnichannel/contacts.search': { + GET: (params: GETOmnichannelContactsSearchProps) => PaginatedResult<{ contacts: ILivechatContact[] }>; + }; '/v1/omnichannel/contact.search': { GET: (params: GETOmnichannelContactSearchProps) => { contact: ILivechatVisitor | null };