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: Contacts verification and merging #33491

Merged
merged 52 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
1d66265
feat: contact verification and merging methods
tapiarafael Oct 8, 2024
26acc1a
test: unit tests for mergeContacts and verifyContactChannel
tapiarafael Oct 8, 2024
00e14fb
feat(sci): on demand migration of Visitors to Contacts (#33594)
pierre-lehnen-rc Oct 15, 2024
5c5f4e5
use the same visitor merge code to merge contacts
pierre-lehnen-rc Oct 15, 2024
ccf15f6
move verifyContactChannel to EE
matheusbsilva137 Oct 15, 2024
572297e
use new callback
matheusbsilva137 Oct 15, 2024
e43c1c8
use overwriteClassOnLicense
matheusbsilva137 Oct 16, 2024
1b3db20
fix tests
pierre-lehnen-rc Oct 15, 2024
de06315
Remove changes related to the new callback
matheusbsilva137 Oct 16, 2024
7bea162
use makeFunction instead of patch
matheusbsilva137 Oct 16, 2024
6b8d274
remove changes related to the OmnichannelService
matheusbsilva137 Oct 16, 2024
4e56113
Create changeset
matheusbsilva137 Oct 16, 2024
d8f98a2
Check for license in patch function
matheusbsilva137 Oct 16, 2024
71f020a
Merge branch 'feat/contact-merging' of https://github.com/RocketChat/…
matheusbsilva137 Oct 16, 2024
f7b82f4
fix lint
matheusbsilva137 Oct 16, 2024
2a65fed
Merge branch 'feat/single-contact-id' into feat/contact-merging
tapiarafael Oct 16, 2024
ae3d30c
fix typecheck and move unit tests to EE
matheusbsilva137 Oct 16, 2024
66c52b7
Merge branch 'feat/contact-merging' of https://github.com/RocketChat/…
matheusbsilva137 Oct 16, 2024
7e849d3
fix unit tests file path
matheusbsilva137 Oct 16, 2024
007dd55
Merge branch 'feat/single-contact-id' into feat/contact-merging
tapiarafael Oct 16, 2024
7d85c79
stub mergeContact function
matheusbsilva137 Oct 16, 2024
205c27f
fix lint
matheusbsilva137 Oct 16, 2024
f4a4ac4
fix function calls in unit tests
matheusbsilva137 Oct 16, 2024
f424e96
fix unit tests stubs
matheusbsilva137 Oct 17, 2024
f520c45
do not await in file root
matheusbsilva137 Oct 17, 2024
826b931
fix new unit tests
matheusbsilva137 Oct 17, 2024
2718537
remove unrelated model methods
matheusbsilva137 Oct 17, 2024
e750e8d
improve readability in unit tests
matheusbsilva137 Oct 17, 2024
12a3eeb
remove mergeContacts tests from verifyContactChannel tests
matheusbsilva137 Oct 17, 2024
96d1d65
move mergeContact to EE
matheusbsilva137 Oct 17, 2024
8f2e9b8
add mergeContacts unit tests
matheusbsilva137 Oct 17, 2024
edbe734
improve test case name
matheusbsilva137 Oct 17, 2024
02d4a25
stub mergeContacts function
matheusbsilva137 Oct 17, 2024
1ff6baf
stop using sinon.useFakeTimers
matheusbsilva137 Oct 17, 2024
b113e28
fix unit test assertion
matheusbsilva137 Oct 17, 2024
c6ffecc
check for specific argument in unit tests
matheusbsilva137 Oct 17, 2024
737dfe4
improve userConverter tests
matheusbsilva137 Oct 17, 2024
fbaa1b9
apply changes requested
matheusbsilva137 Oct 17, 2024
2ae0a83
remove userConverter unit test fix
matheusbsilva137 Oct 17, 2024
23310e4
Merge branch 'feat/single-contact-id' into feat/contact-merging
pierre-lehnen-rc Oct 17, 2024
40d63e5
removed new imports
pierre-lehnen-rc Oct 17, 2024
4db2532
update hasLicense usage
matheusbsilva137 Oct 17, 2024
b483fbf
fix typecheck
matheusbsilva137 Oct 17, 2024
76f98e0
Do not use onLicense
matheusbsilva137 Oct 17, 2024
929b8d6
Do not use onLicense on verifyContactChannel
matheusbsilva137 Oct 17, 2024
f16c80a
update changes requested
matheusbsilva137 Oct 17, 2024
f4f6570
stub patch functions
matheusbsilva137 Oct 17, 2024
3e3c849
fix unit tests
matheusbsilva137 Oct 17, 2024
4e471c7
Remove unrelated model change
matheusbsilva137 Oct 17, 2024
fbd4691
Update ILivechatContactsModel.ts
matheusbsilva137 Oct 17, 2024
155dc1e
fix lint
matheusbsilva137 Oct 17, 2024
02812f8
Merge branch 'feat/contact-merging' of https://github.com/RocketChat/…
matheusbsilva137 Oct 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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<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[]>;
}
Loading