Improved participant matching with additional emails support (#12368)

# Improved participant matching with additional emails support

Closes #8991 

This PR extends the participant matching system to support additional
emails in addition to primary emails for both calendar events and
messages. Previously, the system only matched participants based on
primary emails, missing matches with secondary email addresses.

- Contact creation now consider both primary and additional emails when
checking for existing contacts
- Calendar and message participant listeners now handle both primary and
additional email changes
- Added tests

## To test this PR:

Check that:
- Primary emails take precedence over additional emails in matching
- Case-insensitive email comparisons work correctly
- A contact is not created if a person already exists with the email as
its additional email
- Event listeners handle both creation and update scenarios
- Matching and unmatching logic works for complex email change scenarios
- When unmatching after a change in a primary or secondary email, events
and messages should be rematched if another person has this email as its
primary or secondary email.

---------

Co-authored-by: guillim <guigloo@msn.com>
This commit is contained in:
Raphaël Bosi
2025-06-03 14:36:56 +02:00
committed by GitHub
parent 179365b4bc
commit eed9125945
22 changed files with 2694 additions and 160 deletions

View File

@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import chunk from 'lodash.chunk';
import compact from 'lodash.compact';
import { Any, DeepPartial, Repository } from 'typeorm';
import { DeepPartial, Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@ -20,6 +21,7 @@ import { Contact } from 'src/modules/contact-creation-manager/types/contact.type
import { filterOutSelfAndContactsFromCompanyOrWorkspace } from 'src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util';
import { getDomainNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-domain-name-from-handle.util';
import { getUniqueContactsAndHandles } from 'src/modules/contact-creation-manager/utils/get-unique-contacts-and-handles.util';
import { addPersonEmailFiltersToQueryBuilder } from 'src/modules/match-participant/utils/add-person-email-filters-to-query-builder';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { isWorkDomain, isWorkEmail } from 'src/utils/is-work-email';
@ -81,17 +83,37 @@ export class CreateCompanyAndContactService {
return [];
}
const alreadyCreatedContacts = await personRepository.find({
withDeleted: true,
where: {
emails: { primaryEmail: Any(uniqueHandles) },
},
const queryBuilder = addPersonEmailFiltersToQueryBuilder({
queryBuilder: personRepository.createQueryBuilder('person'),
emails: uniqueHandles,
});
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ emails }) => emails?.primaryEmail?.toLowerCase(),
const rawAlreadyCreatedContacts = await queryBuilder
.orderBy('person.createdAt', 'ASC')
.getMany();
const alreadyCreatedContacts = await personRepository.formatResult(
rawAlreadyCreatedContacts,
);
const alreadyCreatedContactEmails: string[] =
alreadyCreatedContacts?.reduce<string[]>((acc, { emails }) => {
const currentContactEmails: string[] = [];
if (isNonEmptyString(emails?.primaryEmail)) {
currentContactEmails.push(emails.primaryEmail.toLowerCase());
}
if (Array.isArray(emails?.additionalEmails)) {
const additionalEmails = emails.additionalEmails
.filter(isNonEmptyString)
.map((email) => email.toLowerCase());
currentContactEmails.push(...additionalEmails);
}
return [...acc, ...currentContactEmails];
}, []);
const filteredContactsToCreate = uniqueContacts.filter(
(participant) =>
!alreadyCreatedContactEmails.includes(

View File

@ -0,0 +1,260 @@
import { EachTestingContext } from 'twenty-shared/testing';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { computeChangedAdditionalEmails } from 'src/modules/contact-creation-manager/utils/compute-changed-additional-emails';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
type ComputeChangedAdditionalEmailsTestCase = EachTestingContext<{
diff: Partial<ObjectRecordDiff<PersonWorkspaceEntity>>;
expected: {
addedAdditionalEmails: string[];
removedAdditionalEmails: string[];
};
}>;
const testCases: ComputeChangedAdditionalEmailsTestCase[] = [
{
title:
'should return added and removed emails when both before and after are valid arrays',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: ['old1@example.com', 'common@example.com'],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: ['new1@example.com', 'common@example.com'],
},
},
},
expected: {
addedAdditionalEmails: ['new1@example.com'],
removedAdditionalEmails: ['old1@example.com'],
},
},
},
{
title:
'should return all emails as added when before is empty and after has emails',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: [],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: ['new1@example.com', 'new2@example.com'],
},
},
},
expected: {
addedAdditionalEmails: ['new1@example.com', 'new2@example.com'],
removedAdditionalEmails: [],
},
},
},
{
title:
'should return all emails as removed when before has emails and after is empty',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: ['old1@example.com', 'old2@example.com'],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: [],
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: ['old1@example.com', 'old2@example.com'],
},
},
},
{
title: 'should return empty arrays when both before and after are empty',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: [],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: [],
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
{
title:
'should return empty arrays when both before and after have the same emails',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: ['email1@example.com', 'email2@example.com'],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: ['email1@example.com', 'email2@example.com'],
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
{
title: 'should handle case when before additionalEmails is not an array',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: null as any,
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: ['new@example.com'],
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
{
title: 'should handle case when after additionalEmails is not an array',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: ['old@example.com'],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: null as any,
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
{
title:
'should handle case when both before and after additionalEmails are not arrays',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: null as any,
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: undefined as any,
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
{
title: 'should handle case when emails diff is undefined',
context: {
diff: {},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
{
title:
'should handle complex scenario with multiple additions and removals',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: [
'keep1@example.com',
'remove1@example.com',
'keep2@example.com',
'remove2@example.com',
],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: [
'keep1@example.com',
'add1@example.com',
'keep2@example.com',
'add2@example.com',
],
},
},
},
expected: {
addedAdditionalEmails: ['add1@example.com', 'add2@example.com'],
removedAdditionalEmails: ['remove1@example.com', 'remove2@example.com'],
},
},
},
{
title: 'should not be case sensitive when comparing emails',
context: {
diff: {
emails: {
before: {
primaryEmail: 'primary@example.com',
additionalEmails: ['old@example.com'],
},
after: {
primaryEmail: 'primary@example.com',
additionalEmails: ['OLD@example.com'],
},
},
},
expected: {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
},
},
},
];
describe('computeChangedAdditionalEmails', () => {
test.each(testCases)('$title', ({ context: { diff, expected } }) => {
const result = computeChangedAdditionalEmails(diff);
expect(result).toEqual(expected);
});
});

View File

@ -0,0 +1,291 @@
import { EachTestingContext } from 'twenty-shared/testing';
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { hasPrimaryEmailChanged } from 'src/modules/contact-creation-manager/utils/has-primary-email-changed';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
type HasPrimaryEmailChangedTestCase = EachTestingContext<{
diff: Partial<ObjectRecordDiff<PersonWorkspaceEntity>>;
expected: boolean;
}>;
const testCases: HasPrimaryEmailChangedTestCase[] = [
{
title: 'should return true when primary email has changed',
context: {
diff: {
emails: {
before: {
primaryEmail: 'old@example.com',
additionalEmails: [],
},
after: {
primaryEmail: 'new@example.com',
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title: 'should return false when primary email has not changed',
context: {
diff: {
emails: {
before: {
primaryEmail: 'same@example.com',
additionalEmails: ['additional@example.com'],
},
after: {
primaryEmail: 'same@example.com',
additionalEmails: ['different@example.com'],
},
},
},
expected: false,
},
},
{
title: 'should return true when primary email changes from null to a value',
context: {
diff: {
emails: {
before: {
primaryEmail: null as any,
additionalEmails: [],
},
after: {
primaryEmail: 'new@example.com',
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title: 'should return true when primary email changes from a value to null',
context: {
diff: {
emails: {
before: {
primaryEmail: 'old@example.com',
additionalEmails: [],
},
after: {
primaryEmail: null as any,
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title: 'should return false when both primary emails are null',
context: {
diff: {
emails: {
before: {
primaryEmail: null as any,
additionalEmails: [],
},
after: {
primaryEmail: null as any,
additionalEmails: [],
},
},
},
expected: false,
},
},
{
title:
'should return true when primary email changes from undefined to a value',
context: {
diff: {
emails: {
before: {
primaryEmail: undefined as any,
additionalEmails: [],
},
after: {
primaryEmail: 'new@example.com',
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title:
'should return true when primary email changes from a value to undefined',
context: {
diff: {
emails: {
before: {
primaryEmail: 'old@example.com',
additionalEmails: [],
},
after: {
primaryEmail: undefined as any,
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title: 'should return false when both primary emails are undefined',
context: {
diff: {
emails: {
before: {
primaryEmail: undefined as any,
additionalEmails: [],
},
after: {
primaryEmail: undefined as any,
additionalEmails: [],
},
},
},
expected: false,
},
},
{
title:
'should return true when primary email changes from empty string to a value',
context: {
diff: {
emails: {
before: {
primaryEmail: '',
additionalEmails: [],
},
after: {
primaryEmail: 'new@example.com',
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title:
'should return true when primary email changes from a value to empty string',
context: {
diff: {
emails: {
before: {
primaryEmail: 'old@example.com',
additionalEmails: [],
},
after: {
primaryEmail: '',
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title: 'should return false when both primary emails are empty strings',
context: {
diff: {
emails: {
before: {
primaryEmail: '',
additionalEmails: [],
},
after: {
primaryEmail: '',
additionalEmails: [],
},
},
},
expected: false,
},
},
{
title: 'should handle case when emails diff is undefined',
context: {
diff: {},
expected: false,
},
},
{
title: 'should handle case when emails.before is undefined',
context: {
diff: {
emails: {
before: undefined as any,
after: {
primaryEmail: 'new@example.com',
additionalEmails: [],
},
},
},
expected: true,
},
},
{
title: 'should handle case when emails.after is undefined',
context: {
diff: {
emails: {
before: {
primaryEmail: 'old@example.com',
additionalEmails: [],
},
after: undefined as any,
},
},
expected: true,
},
},
{
title:
'should handle case when both emails.before and emails.after are undefined',
context: {
diff: {
emails: {
before: undefined as any,
after: undefined as any,
},
},
expected: false,
},
},
{
title: 'should not be case sensitive when comparing emails',
context: {
diff: {
emails: {
before: {
primaryEmail: 'test@example.com',
additionalEmails: [],
},
after: {
primaryEmail: 'TEST@EXAMPLE.COM',
additionalEmails: [],
},
},
},
expected: false,
},
},
];
describe('hasPrimaryEmailChanged', () => {
test.each(testCases)('$title', ({ context: { diff, expected } }) => {
const result = hasPrimaryEmailChanged(diff);
expect(result).toBe(expected);
});
});

View File

@ -0,0 +1,31 @@
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
export const computeChangedAdditionalEmails = (
diff: Partial<ObjectRecordDiff<PersonWorkspaceEntity>>,
) => {
const before = diff.emails?.before?.additionalEmails as string[];
const after = diff.emails?.after?.additionalEmails as string[];
if (!Array.isArray(before) || !Array.isArray(after)) {
return {
addedAdditionalEmails: [],
removedAdditionalEmails: [],
};
}
const lowerCaseBefore = before.map((email) => email.toLowerCase());
const lowerCaseAfter = after.map((email) => email.toLowerCase());
const addedAdditionalEmails = lowerCaseAfter.filter(
(email) => !lowerCaseBefore.includes(email),
);
const removedAdditionalEmails = lowerCaseBefore.filter(
(email) => !lowerCaseAfter.includes(email),
);
return {
addedAdditionalEmails,
removedAdditionalEmails,
};
};

View File

@ -0,0 +1,11 @@
import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
export const hasPrimaryEmailChanged = (
diff: Partial<ObjectRecordDiff<PersonWorkspaceEntity>>,
) => {
const before = diff.emails?.before?.primaryEmail?.toLowerCase();
const after = diff.emails?.after?.primaryEmail?.toLowerCase();
return before !== after;
};