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: Adds contacts.checkExistence endpoint #34194

Merged
merged 10 commits into from
Dec 19, 2024
6 changes: 6 additions & 0 deletions .changeset/big-timers-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

Adds a new `contacts.checkExistence` endpoint, which allows identifying whether there's already a registered contact using a given email, phone, id or visitor to source association.
15 changes: 15 additions & 0 deletions apps/meteor/app/livechat/server/api/v1/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isGETOmnichannelContactHistoryProps,
isGETOmnichannelContactsChannelsProps,
isGETOmnichannelContactsSearchProps,
isGETOmnichannelContactsCheckExistenceProps,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { Match, check } from 'meteor/check';
Expand Down Expand Up @@ -166,6 +167,20 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'omnichannel/contacts.checkExistence',
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsCheckExistenceProps },
{
async get() {
const { contactId, visitor, email, phone } = this.queryParams;

const contact = await (visitor ? getContactByChannel(visitor) : LivechatContacts.countByContactInfo({ contactId, email, phone }));

return API.v1.success({ exists: !!contact });
},
},
);

API.v1.addRoute(
'omnichannel/contacts.history',
{ authRequired: true, permissionsRequired: ['view-livechat-contact-history'], validateParams: isGETOmnichannelContactHistoryProps },
Expand Down
10 changes: 10 additions & 0 deletions apps/meteor/server/models/raw/LivechatContacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,14 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL

return updatedContact.value;
}

countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise<number> {
const filter = {
...(email && { 'emails.address': email }),
...(phone && { 'phones.phoneNumber': phone }),
...(contactId && { _id: contactId }),
};

return this.countDocuments(filter);
}
}
147 changes: 147 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 @@ -1061,6 +1061,153 @@ describe('LIVECHAT - contacts', () => {
});
});

describe('[GET] omnichannel/contacts.checkExistence', () => {
let contactId: string;
let association: ILivechatContactVisitorAssociation;
let roomId: string;

const email = faker.internet.email().toLowerCase();
const phone = faker.phone.number();

const contact = {
name: faker.person.fullName(),
emails: [email],
phones: [phone],
contactManager: agentUser?._id,
};

before(async () => {
await updatePermission('view-livechat-contact', ['admin']);
const { body } = await request
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
.post(api('omnichannel/contacts'))
.set(credentials)
.send({ ...contact });
contactId = body.contactId;

const visitor = await createVisitor(undefined, contact.name, email, phone);

const room = await createLivechatRoom(visitor.token);
matheusbsilva137 marked this conversation as resolved.
Show resolved Hide resolved
roomId = room._id;
association = {
visitorId: visitor._id,
source: {
type: room.source.type,
id: room.source.id,
},
};
});

after(async () => Promise.all([restorePermissionToRoles('view-livechat-contact'), closeOmnichannelRoom(roomId)]));

it('should confirm a contact exists when checking by contact id', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ contactId });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', true);
});

it('should confirm a contact does not exist when checking by contact id', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ contactId: 'invalid-contact-id' });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', false);
});

it('should confirm a contact exists when checking by visitor association', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ visitor: association });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', true);
});

it('should confirm a contact does not exist when checking by visitor association', async () => {
const res = await request
.get(api(`omnichannel/contacts.checkExistence`))
.set(credentials)
.query({ visitor: { ...association, visitorId: 'invalid-id' } });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', false);
});

it('should confirm a contact exists when checking by email', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ email });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', true);
});

it('should confirm a contact does not exist when checking by email', async () => {
const res = await request
.get(api(`omnichannel/contacts.checkExistence`))
.set(credentials)
.query({ email: 'invalid-email@example.com' });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', false);
});

it('should confirm a contact exists when checking by phone', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ phone });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', true);
});

it('should confirm a contact does not exist when checking by phone', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials).query({ phone: 'invalid-phone' });

expect(res.status).to.be.equal(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('exists', false);
});

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.checkExistence`)).set(credentials).query({ contactId });

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

it('should return an error if all query params are missing', async () => {
const res = await request.get(api(`omnichannel/contacts.checkExistence`)).set(credentials);

expectInvalidParams(res, [
"must have required property 'contactId'",
"must have required property 'email'",
"must have required property 'phone'",
"must have required property 'visitor'",
'must match exactly one schema in oneOf [invalid-params]',
]);
});

it('should return an error if more than one field is provided', async () => {
const res = await request
.get(api(`omnichannel/contacts.checkExistence`))
.set(credentials)
.query({ contactId, visitor: association, email, phone });

expectInvalidParams(res, [
'must NOT have additional properties',
'must NOT have additional properties',
'must NOT have additional properties',
'must NOT have additional properties',
'must match exactly one schema in oneOf [invalid-params]',
]);
});
});

describe('[GET] omnichannel/contacts.search', () => {
let contactId: string;
let visitor: ILivechatVisitor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> {
setChannelVerifiedStatus(visitor: ILivechatContactVisitorAssociation, verified: boolean): Promise<UpdateResult>;
setVerifiedUpdateQuery(verified: boolean, contactUpdater: Updater<ILivechatContact>): Updater<ILivechatContact>;
setFieldAndValueUpdateQuery(field: string, value: string, contactUpdater: Updater<ILivechatContact>): Updater<ILivechatContact>;
countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise<number>;
}
64 changes: 64 additions & 0 deletions packages/rest-typings/src/v1/omnichannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,67 @@ const GETOmnichannelContactsSearchSchema = {

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

type GETOmnichannelContactsCheckExistenceProps = {
contactId?: string;
email?: string;
phone?: string;
visitor?: ILivechatContactVisitorAssociation;
};

const GETOmnichannelContactsCheckExistenceSchema = {
oneOf: [
{
type: 'object',
properties: {
contactId: {
type: 'string',
nullable: false,
isNotEmpty: true,
},
},
required: ['contactId'],
additionalProperties: false,
},
{
type: 'object',
properties: {
email: {
type: 'string',
matheusbsilva137 marked this conversation as resolved.
Show resolved Hide resolved
format: 'basic_email',
nullable: false,
isNotEmpty: true,
},
},
required: ['email'],
additionalProperties: false,
},
{
type: 'object',
properties: {
phone: {
type: 'string',
nullable: false,
isNotEmpty: true,
},
},
required: ['phone'],
additionalProperties: false,
},
{
type: 'object',
properties: {
visitor: ContactVisitorAssociationSchema,
},
required: ['visitor'],
additionalProperties: false,
},
],
};

export const isGETOmnichannelContactsCheckExistenceProps = ajv.compile<GETOmnichannelContactsCheckExistenceProps>(
GETOmnichannelContactsCheckExistenceSchema,
);

type GETOmnichannelContactHistoryProps = PaginatedRequest<{
contactId: string;
source?: string;
Expand Down Expand Up @@ -3867,6 +3928,9 @@ export type OmnichannelEndpoints = {
'/v1/omnichannel/contacts.search': {
GET: (params: GETOmnichannelContactsSearchProps) => PaginatedResult<{ contacts: ILivechatContactWithManagerData[] }>;
};
'/v1/omnichannel/contacts.checkExistence': {
GET: (params: GETOmnichannelContactsCheckExistenceProps) => { exists: boolean };
};
'/v1/omnichannel/contacts.history': {
GET: (params: GETOmnichannelContactHistoryProps) => PaginatedResult<{ history: ContactSearchChatsResult[] }>;
};
Expand Down
Loading