Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: search contacts endpoint #33043

Merged
merged 24 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f3c86e5
feat: add new endpoint to list contacts
tapiarafael Aug 13, 2024
0c78209
refactor: deprecate old endpoint
tapiarafael Aug 13, 2024
c23b87d
refactor: remove comment
tapiarafael Aug 13, 2024
087ca61
fix: remove deprecation warning
tapiarafael Aug 19, 2024
0704cd9
test: ensure that endpoint works as expected
tapiarafael Aug 19, 2024
3627553
refactor: remove console.log
tapiarafael Aug 19, 2024
31680e1
Merge branch 'develop' into feat/search-contacts
tapiarafael Sep 17, 2024
a89dd27
fix: only allow endpoint in test mode
tapiarafael Sep 17, 2024
c10c6fd
refactor: call model directly
tapiarafael Sep 17, 2024
6254901
Merge branch 'develop' into feat/search-contacts
tapiarafael Sep 19, 2024
31550f1
test: ensure that filtering is working
tapiarafael Sep 19, 2024
9e3cbf8
Update apps/meteor/app/livechat/server/lib/Contacts.ts
tapiarafael Sep 19, 2024
e1ec6c0
Merge branch 'develop' into feat/search-contacts
tapiarafael Sep 23, 2024
b14c918
refactor: extract findPaginated query to its own method
tapiarafael Sep 23, 2024
7062548
feat: create index for contact collection
tapiarafael Sep 24, 2024
834ed25
Merge branch 'develop' into feat/search-contacts
tapiarafael Sep 24, 2024
39b8026
Update apps/meteor/server/models/raw/LivechatContacts.ts
tapiarafael Sep 24, 2024
fa0f6fd
feat: improve the indexes for the contact collection
tapiarafael Sep 25, 2024
38ea307
test: move permission test to a own describe block
tapiarafael Sep 25, 2024
40b0de9
Merge branch 'develop' into feat/search-contacts
tapiarafael Sep 25, 2024
63ae4a3
Merge remote-tracking branch 'origin/develop' into feat/search-contacts
tapiarafael Sep 30, 2024
b8187f8
refactor: use new method to verify if is enabled
tapiarafael Sep 30, 2024
d33b883
Merge branch 'develop' into feat/search-contacts
tapiarafael Sep 30, 2024
4f4a48f
Merge branch 'develop' into feat/search-contacts
tapiarafael Oct 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 } from '../../lib/Contacts';
import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems';
import { Contacts, createContact, updateContact, getContacts } 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 (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') {
throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode');
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved
}
pierre-lehnen-rc marked this conversation as resolved.
Show resolved Hide resolved

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);
},
},
);
41 changes: 40 additions & 1 deletion apps/meteor/app/livechat/server/lib/Contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
Subscriptions,
LivechatContacts,
} from '@rocket.chat/models';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb';
import type { Filter, MatchKeysAndValues, OnlyFieldsOfType, RootFilterOperators } from 'mongodb';

import { callbacks } from '../../../../lib/callbacks';
import { trim } from '../../../../lib/utils/stringUtils';
Expand Down Expand Up @@ -62,6 +64,13 @@
channels?: ILivechatContactChannel[];
};

type GetContactsParams = {
searchText?: string;
count: number;
offset: number;
sort: Record<string, number>;
};

export const Contacts = {
async registerContact({
token,
Expand Down Expand Up @@ -248,6 +257,36 @@
return updatedContact;
}

export async function getContacts(params: GetContactsParams): Promise<PaginatedResult<{ contacts: ILivechatContact[] }>> {
const { searchText, count, offset, sort } = params;

const searchRegex = escapeRegExp(searchText || '');
const match: Filter<ILivechatContact & RootFilterOperators<ILivechatContact>> = {
$or: [
{ name: { $regex: searchRegex }, $options: 'i'},

Check failure on line 266 in apps/meteor/app/livechat/server/lib/Contacts.ts

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Insert `·`
{ emails: { $regex: searchRegex }, $options: 'i'},

Check failure on line 267 in apps/meteor/app/livechat/server/lib/Contacts.ts

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Insert `·`
{ phones: { $regex: searchRegex }, $options: 'i'},

Check failure on line 268 in apps/meteor/app/livechat/server/lib/Contacts.ts

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Insert `·`
]

Check failure on line 269 in apps/meteor/app/livechat/server/lib/Contacts.ts

View workflow job for this annotation

GitHub Actions / 🔎 Code Check / Code Lint

Insert `,`
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved
};

const { cursor, totalCount } = LivechatContacts.findPaginated(
{ ...match },
{
sort,
skip: offset,
limit: count,
allowDiskUse: true,
},
);
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved

return {
contacts: await cursor.toArray(),
count,
offset,
total: await totalCount,
};
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved
}

async function getAllowedCustomFields(): Promise<ILivechatCustomField[]> {
return LivechatCustomField.findByScope(
'visitor',
Expand Down
91 changes: 91 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,95 @@ 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 });
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved
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);
});

it("should return an error if user doesn't have 'view-livechat-contact' permission", async () => {
await removePermissionFromAllRoles('view-livechat-contact');

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]');

await restorePermissionToRoles('view-livechat-contact');
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
33 changes: 33 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,36 @@ const GETOmnichannelContactsSchema = {

export const isGETOmnichannelContactsProps = ajv.compile<GETOmnichannelContactsProps>(GETOmnichannelContactsSchema);

type GETOmnichannelContactsSearchProps = PaginatedRequest<{
searchText: string;
}>;

const GETOmnichannelContactsSearchSchema = {
type: 'object',
properties: {
count: {
type: 'number',
nullable: true,
tapiarafael marked this conversation as resolved.
Show resolved Hide resolved
},
offset: {
type: 'number',
nullable: true,
},
sort: {
type: 'string',
nullable: true,
},
searchText: {
type: 'string',
nullable: true,
},
},
required: [],
additionalProperties: false,
};

export const isGETOmnichannelContactsSearchProps = ajv.compile<GETOmnichannelContactsSearchProps>(GETOmnichannelContactsSearchSchema);

type GETOmnichannelContactProps = { contactId: string };

const GETOmnichannelContactSchema = {
Expand Down Expand Up @@ -3776,6 +3806,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
Loading