Skip to content

Commit

Permalink
feat: search contacts endpoint (RocketChat#33043)
Browse files Browse the repository at this point in the history
  • Loading branch information
tapiarafael authored Oct 2, 2024
1 parent bcacbb1 commit 6208dff
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 6 deletions.
29 changes: 27 additions & 2 deletions apps/meteor/app/livechat/server/api/v1/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
},
},
);
29 changes: 28 additions & 1 deletion apps/meteor/app/livechat/server/lib/Contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,6 +64,13 @@ type UpdateContactParams = {
channels?: ILivechatContactChannel[];
};

type GetContactsParams = {
searchText?: string;
count: number;
offset: number;
sort: Sort;
};

export const Contacts = {
async registerContact({
token,
Expand Down Expand Up @@ -225,6 +233,25 @@ export async function updateContact(params: UpdateContactParams): Promise<ILivec
return updatedContact;
}

export async function getContacts(params: GetContactsParams): Promise<PaginatedResult<{ contacts: ILivechatContact[] }>> {
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<Pick<ILivechatCustomField, '_id' | 'label' | 'regexp' | 'required'>[]> {
return LivechatCustomField.findByScope(
'visitor',
Expand Down
47 changes: 45 additions & 2 deletions apps/meteor/server/models/raw/LivechatContacts.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,6 +10,29 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> 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<ILivechatContact>): Promise<ILivechatContact> {
const updatedValue = await this.findOneAndUpdate(
{ _id: contactId },
Expand All @@ -17,4 +41,23 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
);
return updatedValue.value as ILivechatContact;
}

findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>> {
const searchRegex = escapeRegExp(searchText || '');
const match: Filter<ILivechatContact & RootFilterOperators<ILivechatContact>> = {
$or: [
{ name: { $regex: searchRegex, $options: 'i' } },
{ emails: { $regex: searchRegex, $options: 'i' } },
{ phones: { $regex: searchRegex, $options: 'i' } },
],
};

return this.findPaginated(
{ ...match },
{
allowDiskUse: true,
...options,
},
);
}
}
94 changes: 94 additions & 0 deletions apps/meteor/tests/end-to-end/api/livechat/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
});
});
});
});
4 changes: 3 additions & 1 deletion packages/model-typings/src/models/ILivechatContactsModel.ts
Original file line number Diff line number Diff line change
@@ -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<ILivechatContact> {
updateContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact>;
findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>>;
}
29 changes: 29 additions & 0 deletions packages/rest-typings/src/v1/omnichannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,32 @@ const GETOmnichannelContactsSchema = {

export const isGETOmnichannelContactsProps = ajv.compile<GETOmnichannelContactsProps>(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<GETOmnichannelContactsSearchProps>(GETOmnichannelContactsSearchSchema);

type GETOmnichannelContactProps = { contactId: string };

const GETOmnichannelContactSchema = {
Expand Down Expand Up @@ -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 };
Expand Down

0 comments on commit 6208dff

Please sign in to comment.