Skip to content

Commit

Permalink
feat: Contacts verification and merging (#33491)
Browse files Browse the repository at this point in the history
* feat: contact verification and merging methods

---------

Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>
Co-authored-by: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 17, 2024
1 parent 8e83b07 commit bdaf64f
Show file tree
Hide file tree
Showing 11 changed files with 281 additions and 6 deletions.
7 changes: 7 additions & 0 deletions .changeset/metal-flowers-sneeze.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 4 additions & 5 deletions apps/meteor/app/livechat/server/lib/ContactMerger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILivechatContact>['$addToSet'] = {
...(newPhones.length ? { phones: newPhones.map((phoneNumber) => ({ phoneNumber })) } : {}),
Expand All @@ -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<void> {
const fields = await ContactMerger.getAllFieldsFromContact(source);
await ContactMerger.mergeFieldsIntoContact(fields, target);
}
}
13 changes: 13 additions & 0 deletions apps/meteor/app/livechat/server/lib/Contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -528,3 +537,7 @@ export async function validateContactManager(contactManagerUserId: string) {
throw new Error('error-contact-manager-not-found');
}
}

export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise<ILivechatContact | null> => null);

export const mergeContacts = makeFunction(async (_contactId: string, _visitorId: string): Promise<ILivechatContact | null> => null);
2 changes: 2 additions & 0 deletions apps/meteor/ee/server/patches/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import './closeBusinessHour';
import './getInstanceList';
import './isDepartmentCreationAvailable';
import './verifyContactChannel';
import './mergeContacts';
33 changes: 33 additions & 0 deletions apps/meteor/ee/server/patches/mergeContacts.ts
Original file line number Diff line number Diff line change
@@ -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<ILivechatContact | null> => {
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'));
32 changes: 32 additions & 0 deletions apps/meteor/ee/server/patches/verifyContactChannel.ts
Original file line number Diff line number Diff line change
@@ -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<ILivechatContact | null> => {
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'));
Original file line number Diff line number Diff line change
@@ -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;
});
});
Original file line number Diff line number Diff line change
@@ -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'));
});
});
26 changes: 26 additions & 0 deletions apps/meteor/server/models/raw/LivechatContacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
FindCursor,
IndexDescription,
UpdateResult,
UpdateFilter,
} from 'mongodb';

import { BaseRaw } from './BaseRaw';
Expand Down Expand Up @@ -78,6 +79,15 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
return updatedValue.value as ILivechatContact;
}

async updateContactChannel(contactId: string, visitorId: string, data: UpdateFilter<ILivechatContact>['$set']): Promise<UpdateResult> {
return this.updateOne(
{ '_id': contactId, 'channels.visitorId': visitorId },
{
$set: data,
},
);
}

findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>> {
const searchRegex = escapeRegExp(searchText || '');
const match: Filter<ILivechatContact & RootFilterOperators<ILivechatContact>> = {
Expand Down Expand Up @@ -146,4 +156,20 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
async updateLastChatById(contactId: string, visitorId: string, lastChat: ILivechatContact['lastChat']): Promise<UpdateResult> {
return this.updateOne({ '_id': contactId, 'channels.visitorId': visitorId }, { $set: { lastChat, 'channels.$.lastChat': lastChat } });
}

async findSimilarVerifiedContacts(
{ field, value }: Pick<ILivechatContactChannel, 'field' | 'value'>,
originalContactId: string,
options?: FindOptions<ILivechatContact>,
): Promise<ILivechatContact[]> {
return this.find(
{
'channels.field': field,
'channels.value': value,
'channels.verified': true,
'_id': { $ne: originalContactId },
},
options,
).toArray();
}
}
2 changes: 2 additions & 0 deletions packages/core-typings/src/IRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion packages/model-typings/src/models/ILivechatContactsModel.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,6 +9,7 @@ export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> {
): Promise<ILivechatContact['_id']>;
upsertContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact | null>;
updateContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact>;
updateContactChannel(contactId: string, visitorId: string, data: UpdateFilter<ILivechatContact>['$set']): Promise<UpdateResult>;
addChannel(contactId: string, channel: ILivechatContactChannel): Promise<void>;
findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>>;
updateLastChatById(contactId: string, visitorId: string, lastChat: ILivechatContact['lastChat']): Promise<UpdateResult>;
Expand All @@ -17,4 +18,9 @@ export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> {
visitorId: ILivechatVisitor['_id'],
options?: FindOptions<ILivechatContact>,
): Promise<T | null>;
findSimilarVerifiedContacts(
channel: Pick<ILivechatContactChannel, 'field' | 'value'>,
originalContactId: string,
options?: FindOptions<ILivechatContact>,
): Promise<ILivechatContact[]>;
}

0 comments on commit bdaf64f

Please sign in to comment.