From 657ba0c67137343682bb99c2decc99e6284f891e Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Mon, 16 Dec 2024 23:00:09 -0300 Subject: [PATCH 1/7] feat: Add contacts.checkExistence endpoint --- .../app/livechat/server/api/v1/contact.ts | 20 ++++++ packages/rest-typings/src/v1/omnichannel.ts | 63 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 0baa5584a243..fc31af669bfb 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -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'; @@ -166,6 +167,25 @@ 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 filter = { + ...(email && { 'emails.address': email }), + ...(phone && { 'phones.phoneNumber': phone }), + ...(contactId && { _id: contactId }), + }; + + const contact = await (visitor ? getContactByChannel(visitor) : LivechatContacts.countDocuments(filter)); + + return API.v1.success({ exists: !!contact }); + }, + }, +); + API.v1.addRoute( 'omnichannel/contacts.history', { authRequired: true, permissionsRequired: ['view-livechat-contact-history'], validateParams: isGETOmnichannelContactHistoryProps }, diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 37a259187b66..916657d15c1c 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1418,6 +1418,66 @@ const GETOmnichannelContactsSearchSchema = { export const isGETOmnichannelContactsSearchProps = ajv.compile(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', + 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( + GETOmnichannelContactsCheckExistenceSchema, +); + type GETOmnichannelContactHistoryProps = PaginatedRequest<{ contactId: string; source?: string; @@ -3867,6 +3927,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[] }>; }; From fb9af5313d092d9b3e6ba35b8a4e8776b6c01279 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Mon, 16 Dec 2024 23:00:31 -0300 Subject: [PATCH 2/7] tests: add end-to-end tests --- .../tests/end-to-end/api/livechat/contacts.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) 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 daaffb1f9eb3..2d4968b76f21 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -950,6 +950,150 @@ describe('LIVECHAT - contacts', () => { }); }); + describe('[GET] omnichannel/contacts.checkExistence', () => { + let contactId: string; + let association: ILivechatContactVisitorAssociation; + + 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 + .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); + association = { + visitorId: visitor._id, + source: { + type: room.source.type, + id: room.source.id, + }, + }; + }); + + after(async () => { + await restorePermissionToRoles('view-livechat-contact'); + }); + + 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' }); + + 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; From 764107aa59f55380e69837942f33b4afa79b58db Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Mon, 16 Dec 2024 23:18:56 -0300 Subject: [PATCH 3/7] Add changeset --- .changeset/big-timers-relax.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/big-timers-relax.md diff --git a/.changeset/big-timers-relax.md b/.changeset/big-timers-relax.md new file mode 100644 index 000000000000..651bcff90bb6 --- /dev/null +++ b/.changeset/big-timers-relax.md @@ -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. From 029c90471862d9847a2171cdd8391d97c7b42d1c Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Tue, 17 Dec 2024 12:17:53 -0300 Subject: [PATCH 4/7] apply requested changes --- apps/meteor/tests/end-to-end/api/livechat/contacts.ts | 6 +++--- packages/rest-typings/src/v1/omnichannel.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) 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 2d4968b76f21..f3c3a455e0f6 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -953,6 +953,7 @@ 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(); @@ -975,6 +976,7 @@ describe('LIVECHAT - contacts', () => { const visitor = await createVisitor(undefined, contact.name, email, phone); const room = await createLivechatRoom(visitor.token); + roomId = room._id; association = { visitorId: visitor._id, source: { @@ -984,9 +986,7 @@ describe('LIVECHAT - contacts', () => { }; }); - after(async () => { - await restorePermissionToRoles('view-livechat-contact'); - }); + 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 }); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 916657d15c1c..869ef6b10cbf 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1444,6 +1444,7 @@ const GETOmnichannelContactsCheckExistenceSchema = { properties: { email: { type: 'string', + format: 'basic_email', nullable: false, isNotEmpty: true, }, From 077481f5e3e14781ed8d7b022f516b99f173e1bf Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Tue, 17 Dec 2024 12:52:42 -0300 Subject: [PATCH 5/7] Move contact count query to model --- apps/meteor/app/livechat/server/api/v1/contact.ts | 7 +------ apps/meteor/server/models/raw/LivechatContacts.ts | 10 ++++++++++ .../model-typings/src/models/ILivechatContactsModel.ts | 1 + 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index fc31af669bfb..1dcb42cefe6b 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -173,13 +173,8 @@ API.v1.addRoute( { async get() { const { contactId, visitor, email, phone } = this.queryParams; - const filter = { - ...(email && { 'emails.address': email }), - ...(phone && { 'phones.phoneNumber': phone }), - ...(contactId && { _id: contactId }), - }; - const contact = await (visitor ? getContactByChannel(visitor) : LivechatContacts.countDocuments(filter)); + const contact = await (visitor ? getContactByChannel(visitor) : LivechatContacts.countByContactInfo({ contactId, email, phone })); return API.v1.success({ exists: !!contact }); }, diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index 4e80f23956e6..0ed569c49a86 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -249,4 +249,14 @@ export class LivechatContactsRaw extends BaseRaw implements IL return updatedContact.value; } + + countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise { + const filter = { + ...(email && { 'emails.address': email }), + ...(phone && { 'phones.phoneNumber': phone }), + ...(contactId && { _id: contactId }), + }; + + return this.countDocuments(filter); + } } diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 5cf68b15449c..b4c9a22bad83 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -44,4 +44,5 @@ export interface ILivechatContactsModel extends IBaseModel { ): Promise; findAllByVisitorId(visitorId: string): FindCursor; addEmail(contactId: string, email: string): Promise; + countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise; } From 0613965086edbe706c09e050672c94957bfa67d6 Mon Sep 17 00:00:00 2001 From: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:50:15 -0300 Subject: [PATCH 6/7] Fix end-to-end tests --- apps/meteor/tests/end-to-end/api/livechat/contacts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f3c3a455e0f6..df55832de20c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -1032,7 +1032,7 @@ describe('LIVECHAT - contacts', () => { }); 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' }); + 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); From 454cbf88926fcd641a96636c2a3701f95642bc09 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Tue, 17 Dec 2024 18:09:30 -0300 Subject: [PATCH 7/7] fix lint --- apps/meteor/tests/end-to-end/api/livechat/contacts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 df55832de20c..dfd41af8ecde 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -1032,7 +1032,10 @@ describe('LIVECHAT - contacts', () => { }); 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' }); + 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);