From b845fc0093cfaf59093d1e99ccaae77ab741354a Mon Sep 17 00:00:00 2001 From: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:53:30 -0300 Subject: [PATCH] feat: Adds `contacts.checkExistence` endpoint (#34194) --- .changeset/big-timers-relax.md | 6 + .../app/livechat/server/api/v1/contact.ts | 15 ++ .../server/models/raw/LivechatContacts.ts | 10 ++ .../tests/end-to-end/api/livechat/contacts.ts | 147 ++++++++++++++++++ .../src/models/ILivechatContactsModel.ts | 1 + packages/rest-typings/src/v1/omnichannel.ts | 64 ++++++++ 6 files changed, 243 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. diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 0baa5584a243..1dcb42cefe6b 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,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 }, diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index 572530252d76..796edb3bc059 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -262,4 +262,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/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index fc87d32fc5da..ffb05e40f462 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -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 + .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); + 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; diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 744236c5fa74..8501271ab5f4 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -48,4 +48,5 @@ export interface ILivechatContactsModel extends IBaseModel { setChannelVerifiedStatus(visitor: ILivechatContactVisitorAssociation, verified: boolean): Promise; setVerifiedUpdateQuery(verified: boolean, contactUpdater: Updater): Updater; setFieldAndValueUpdateQuery(field: string, value: string, contactUpdater: Updater): Updater; + countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise; } diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 37a259187b66..869ef6b10cbf 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1418,6 +1418,67 @@ 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', + 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( + GETOmnichannelContactsCheckExistenceSchema, +); + type GETOmnichannelContactHistoryProps = PaginatedRequest<{ contactId: string; source?: string; @@ -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[] }>; };