diff --git a/.changeset/metal-flowers-sneeze.md b/.changeset/metal-flowers-sneeze.md new file mode 100644 index 000000000000..fec87f1581bb --- /dev/null +++ b/.changeset/metal-flowers-sneeze.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +--- + +Adds new EE capability to allow merging similar verified visitor contacts diff --git a/apps/meteor/app/livechat/server/lib/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/ContactMerger.ts index 923d4d985ac6..eadcdfbc7185 100644 --- a/apps/meteor/app/livechat/server/lib/ContactMerger.ts +++ b/apps/meteor/app/livechat/server/lib/ContactMerger.ts @@ -252,6 +252,10 @@ export class ContactMerger { ...customFieldConflicts.map(({ type, value }): ILivechatContactConflictingField => ({ field: type, value })), ]; + if (allConflicts.length) { + dataToSet.hasConflicts = true; + } + // Phones, Emails and Channels are simply added to the contact's existing list const dataToAdd: UpdateFilter['$addToSet'] = { ...(newPhones.length ? { phones: newPhones.map((phoneNumber) => ({ phoneNumber })) } : {}), @@ -274,9 +278,4 @@ export class ContactMerger { const fields = await ContactMerger.getAllFieldsFromVisitor(visitor); await ContactMerger.mergeFieldsIntoContact(fields, contact); } - - public static async mergeContacts(source: ILivechatContact, target: ILivechatContact): Promise { - const fields = await ContactMerger.getAllFieldsFromContact(source); - await ContactMerger.mergeFieldsIntoContact(fields, target); - } } diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index 778267323519..95cf952f9006 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -18,6 +18,7 @@ import { Subscriptions, LivechatContacts, } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -57,6 +58,14 @@ type CreateContactParams = { channels?: ILivechatContactChannel[]; }; +type VerifyContactChannelParams = { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; +}; + type UpdateContactParams = { contactId: string; name?: string; @@ -526,3 +535,7 @@ export async function validateContactManager(contactManagerUserId: string) { throw new Error('error-contact-manager-not-found'); } } + +export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise => null); + +export const mergeContacts = makeFunction(async (_contactId: string, _visitorId: string): Promise => null); diff --git a/apps/meteor/ee/server/patches/index.ts b/apps/meteor/ee/server/patches/index.ts index ab3f4bf147c2..49f84bfbf479 100644 --- a/apps/meteor/ee/server/patches/index.ts +++ b/apps/meteor/ee/server/patches/index.ts @@ -1,3 +1,5 @@ import './closeBusinessHour'; import './getInstanceList'; import './isDepartmentCreationAvailable'; +import './verifyContactChannel'; +import './mergeContacts'; diff --git a/apps/meteor/ee/server/patches/mergeContacts.ts b/apps/meteor/ee/server/patches/mergeContacts.ts new file mode 100644 index 000000000000..37985662751f --- /dev/null +++ b/apps/meteor/ee/server/patches/mergeContacts.ts @@ -0,0 +1,33 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { ContactMerger } from '../../../app/livechat/server/lib/ContactMerger'; +import { mergeContacts } from '../../../app/livechat/server/lib/Contacts'; + +export const runMergeContacts = async (_next: any, contactId: string, visitorId: string): Promise => { + const originalContact = (await LivechatContacts.findOneById(contactId)) as ILivechatContact; + if (!originalContact) { + throw new Error('error-invalid-contact'); + } + + const channel = originalContact.channels?.find((channel: ILivechatContactChannel) => channel.visitorId === visitorId); + if (!channel) { + throw new Error('error-invalid-channel'); + } + const similarContacts: ILivechatContact[] = await LivechatContacts.findSimilarVerifiedContacts(channel, contactId); + + if (!similarContacts.length) { + return originalContact; + } + + for await (const similarContact of similarContacts) { + const fields = await ContactMerger.getAllFieldsFromContact(similarContact); + await ContactMerger.mergeFieldsIntoContact(fields, originalContact); + } + + await LivechatContacts.deleteMany({ _id: { $in: similarContacts.map((c) => c._id) } }); + return LivechatContacts.findOneById(contactId); +}; + +mergeContacts.patch(runMergeContacts, () => License.hasModule('contact-id-verification')); diff --git a/apps/meteor/ee/server/patches/verifyContactChannel.ts b/apps/meteor/ee/server/patches/verifyContactChannel.ts new file mode 100644 index 000000000000..72486651f354 --- /dev/null +++ b/apps/meteor/ee/server/patches/verifyContactChannel.ts @@ -0,0 +1,32 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; + +import { verifyContactChannel, mergeContacts } from '../../../app/livechat/server/lib/Contacts'; + +export const runVerifyContactChannel = async ( + _next: any, + params: { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; + }, +): Promise => { + const { contactId, field, value, visitorId, roomId } = params; + + await LivechatContacts.updateContactChannel(contactId, visitorId, { + 'unknown': false, + 'channels.$.verified': true, + 'channels.$.verifiedAt': new Date(), + 'channels.$.field': field, + 'channels.$.value': value, + }); + + await LivechatRooms.update({ _id: roomId }, { $set: { verified: true } }); + + return mergeContacts(contactId, visitorId); +}; + +verifyContactChannel.patch(runVerifyContactChannel, () => License.hasModule('contact-id-verification')); diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts new file mode 100644 index 000000000000..d40fef020c60 --- /dev/null +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts @@ -0,0 +1,100 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneById: sinon.stub(), + findSimilarVerifiedContacts: sinon.stub(), + deleteMany: sinon.stub(), + }, +}; + +const contactMergerStub = { + getAllFieldsFromContact: sinon.stub(), + mergeFieldsIntoContact: sinon.stub(), +}; + +const { runMergeContacts } = proxyquire.noCallThru().load('../../../../../../server/patches/mergeContacts', { + '../../../app/livechat/server/lib/Contacts': { mergeContacts: { patch: sinon.stub() } }, + '../../../app/livechat/server/lib/ContactMerger': { ContactMerger: contactMergerStub }, + '@rocket.chat/models': modelsMock, +}); + +describe('mergeContacts', () => { + const targetChannel = { + name: 'channelName', + visitorId: 'visitorId', + verified: true, + verifiedAt: new Date(), + field: 'field', + value: 'value', + }; + + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.LivechatContacts.findSimilarVerifiedContacts.reset(); + modelsMock.LivechatContacts.deleteMany.reset(); + contactMergerStub.getAllFieldsFromContact.reset(); + contactMergerStub.mergeFieldsIntoContact.reset(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should throw an error if contact does not exist', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + + await expect(runMergeContacts(() => undefined, 'invalidId', 'visitorId')).to.be.rejectedWith('error-invalid-contact'); + }); + + it('should throw an error if contact channel does not exist', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ + _id: 'contactId', + channels: [{ name: 'channelName', visitorId: 'visitorId' }], + }); + + await expect(runMergeContacts(() => undefined, 'contactId', 'invalidVisitor')).to.be.rejectedWith('error-invalid-channel'); + }); + + it('should do nothing if there are no similar verified contacts', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ _id: 'contactId', channels: [targetChannel] }); + modelsMock.LivechatContacts.findSimilarVerifiedContacts.resolves([]); + + await runMergeContacts(() => undefined, 'contactId', 'visitorId'); + + expect(modelsMock.LivechatContacts.findOneById.calledOnceWith('contactId')).to.be.true; + expect(modelsMock.LivechatContacts.findSimilarVerifiedContacts.calledOnceWith(targetChannel, 'contactId')).to.be.true; + expect(modelsMock.LivechatContacts.deleteMany.notCalled).to.be.true; + expect(contactMergerStub.getAllFieldsFromContact.notCalled).to.be.true; + expect(contactMergerStub.mergeFieldsIntoContact.notCalled).to.be.true; + }); + + it('should be able to merge similar contacts', async () => { + const similarContact = { + _id: 'differentId', + emails: ['email2'], + phones: ['phone2'], + channels: [{ name: 'channelName2', visitorId: 'visitorId2', field: 'field', value: 'value' }], + }; + const originalContact = { + _id: 'contactId', + emails: ['email1'], + phones: ['phone1'], + channels: [targetChannel], + }; + + modelsMock.LivechatContacts.findOneById.resolves(originalContact); + modelsMock.LivechatContacts.findSimilarVerifiedContacts.resolves([similarContact]); + + await runMergeContacts(() => undefined, 'contactId', 'visitorId'); + + expect(modelsMock.LivechatContacts.findOneById.calledTwice).to.be.true; + expect(modelsMock.LivechatContacts.findOneById.calledWith('contactId')).to.be.true; + expect(modelsMock.LivechatContacts.findSimilarVerifiedContacts.calledOnceWith(targetChannel, 'contactId')).to.be.true; + expect(contactMergerStub.getAllFieldsFromContact.calledOnceWith(similarContact)).to.be.true; + expect(contactMergerStub.mergeFieldsIntoContact.getCall(0).args[1]).to.be.deep.equal(originalContact); + expect(modelsMock.LivechatContacts.deleteMany.calledOnceWith({ _id: { $in: ['differentId'] } })).to.be.true; + }); +}); diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts new file mode 100644 index 000000000000..d702d2a07bd6 --- /dev/null +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts @@ -0,0 +1,55 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + updateContactChannel: sinon.stub(), + }, + LivechatRooms: { + update: sinon.stub(), + }, +}; + +const mergeContactsStub = sinon.stub(); + +const { runVerifyContactChannel } = proxyquire.noCallThru().load('../../../../../../server/patches/verifyContactChannel', { + '../../../app/livechat/server/lib/Contacts': { mergeContacts: mergeContactsStub, verifyContactChannel: { patch: sinon.stub() } }, + '@rocket.chat/models': modelsMock, +}); + +describe('verifyContactChannel', () => { + beforeEach(() => { + modelsMock.LivechatContacts.updateContactChannel.reset(); + modelsMock.LivechatRooms.update.reset(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should be able to verify a contact channel', async () => { + await runVerifyContactChannel(() => undefined, { + contactId: 'contactId', + field: 'field', + value: 'value', + visitorId: 'visitorId', + roomId: 'roomId', + }); + + expect( + modelsMock.LivechatContacts.updateContactChannel.calledOnceWith( + 'contactId', + 'visitorId', + sinon.match({ + 'unknown': false, + 'channels.$.verified': true, + 'channels.$.field': 'field', + 'channels.$.value': 'value', + }), + ), + ).to.be.true; + expect(modelsMock.LivechatRooms.update.calledOnceWith({ _id: 'roomId' }, { $set: { verified: true } })).to.be.true; + expect(mergeContactsStub.calledOnceWith('contactId', 'visitorId')); + }); +}); diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index 5561e0c5452a..04d5e03e686f 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -17,6 +17,7 @@ import type { FindCursor, IndexDescription, UpdateResult, + UpdateFilter, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -78,6 +79,15 @@ export class LivechatContactsRaw extends BaseRaw implements IL return updatedValue.value as ILivechatContact; } + async updateContactChannel(contactId: string, visitorId: string, data: UpdateFilter['$set']): Promise { + return this.updateOne( + { '_id': contactId, 'channels.visitorId': visitorId }, + { + $set: data, + }, + ); + } + findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated> { const searchRegex = escapeRegExp(searchText || ''); const match: Filter> = { @@ -146,4 +156,20 @@ export class LivechatContactsRaw extends BaseRaw implements IL async updateLastChatById(contactId: string, visitorId: string, lastChat: ILivechatContact['lastChat']): Promise { return this.updateOne({ '_id': contactId, 'channels.visitorId': visitorId }, { $set: { lastChat, 'channels.$.lastChat': lastChat } }); } + + async findSimilarVerifiedContacts( + { field, value }: Pick, + originalContactId: string, + options?: FindOptions, + ): Promise { + return this.find( + { + 'channels.field': field, + 'channels.value': value, + 'channels.verified': true, + '_id': { $ne: originalContactId }, + }, + options, + ).toArray(); + } } diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index eae9854e4ed2..21dc1b76d6ef 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -320,6 +320,8 @@ export interface IOmnichannelRoom extends IOmnichannelGenericRoom { // which is controlled by Livechat_auto_transfer_chat_timeout setting autoTransferredAt?: Date; autoTransferOngoing?: boolean; + + verified?: boolean; } export interface IVoipRoom extends IOmnichannelGenericRoom { diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 55f7a767b296..5c291745e2e4 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -1,5 +1,5 @@ import type { AtLeast, ILivechatContact, ILivechatContactChannel, ILivechatVisitor } from '@rocket.chat/core-typings'; -import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; +import type { Document, FindCursor, FindOptions, UpdateResult, UpdateFilter } from 'mongodb'; import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel'; @@ -9,6 +9,7 @@ export interface ILivechatContactsModel extends IBaseModel { ): Promise; upsertContact(contactId: string, data: Partial): Promise; updateContact(contactId: string, data: Partial): Promise; + updateContactChannel(contactId: string, visitorId: string, data: UpdateFilter['$set']): Promise; addChannel(contactId: string, channel: ILivechatContactChannel): Promise; findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated>; updateLastChatById(contactId: string, visitorId: string, lastChat: ILivechatContact['lastChat']): Promise; @@ -17,4 +18,9 @@ export interface ILivechatContactsModel extends IBaseModel { visitorId: ILivechatVisitor['_id'], options?: FindOptions, ): Promise; + findSimilarVerifiedContacts( + channel: Pick, + originalContactId: string, + options?: FindOptions, + ): Promise; }