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

@ -3,15 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { MatchParticipantService } from 'src/modules/match-participant/match-participant.service';
export type CalendarEventParticipantMatchParticipantJobData = {
workspaceId: string;
isPrimaryEmail: boolean;
email: string;
personId?: string;
workspaceMemberId?: string;
@ -32,7 +33,8 @@ export class CalendarEventParticipantMatchParticipantJob {
async handle(
data: CalendarEventParticipantMatchParticipantJobData,
): Promise<void> {
const { workspaceId, email, personId, workspaceMemberId } = data;
const { workspaceId, isPrimaryEmail, email, personId, workspaceMemberId } =
data;
const workspace = await this.workspaceRepository.findOne({
where: {
@ -44,11 +46,23 @@ export class CalendarEventParticipantMatchParticipantJob {
return;
}
await this.matchParticipantService.matchParticipantsAfterPersonOrWorkspaceMemberCreation(
email,
'calendarEventParticipant',
personId,
workspaceMemberId,
);
if (personId) {
await this.matchParticipantService.matchParticipantsAfterPersonCreation({
handle: email,
isPrimaryEmail,
objectMetadataName: 'calendarEventParticipant',
personId,
});
}
if (workspaceMemberId) {
await this.matchParticipantService.matchParticipantsAfterWorkspaceMemberCreation(
{
handle: email,
objectMetadataName: 'calendarEventParticipant',
workspaceMemberId,
},
);
}
}
}

View File

@ -28,11 +28,11 @@ export class CalendarEventParticipantUnmatchParticipantJob {
): Promise<void> {
const { email, personId, workspaceMemberId } = data;
await this.matchParticipantService.unmatchParticipants(
email,
'calendarEventParticipant',
await this.matchParticipantService.unmatchParticipants({
handle: email,
objectMetadataName: 'calendarEventParticipant',
personId,
workspaceMemberId,
);
});
}
}

View File

@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { objectRecordChangedProperties as objectRecordUpdateEventChangedProperties } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-properties.util';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@ -15,9 +20,9 @@ import {
CalendarEventParticipantUnmatchParticipantJob,
CalendarEventParticipantUnmatchParticipantJobData,
} from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-unmatch-participant.job';
import { computeChangedAdditionalEmails } from 'src/modules/contact-creation-manager/utils/compute-changed-additional-emails';
import { hasPrimaryEmailChanged } from 'src/modules/contact-creation-manager/utils/has-primary-email-changed';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class CalendarEventParticipantPersonListener {
@ -33,19 +38,43 @@ export class CalendarEventParticipantPersonListener {
>,
) {
for (const eventPayload of payload.events) {
if (eventPayload.properties.after.emails?.primaryEmail === null) {
continue;
const jobPromises: Promise<void>[] = [];
if (isDefined(eventPayload.properties.after.emails?.primaryEmail)) {
// TODO: modify this job to take an array of participants to match
jobPromises.push(
this.messageQueueService.add<CalendarEventParticipantMatchParticipantJobData>(
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
isPrimaryEmail: true,
personId: eventPayload.recordId,
},
),
);
}
// TODO: modify this job to take an array of participants to match
await this.messageQueueService.add<CalendarEventParticipantMatchParticipantJobData>(
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
personId: eventPayload.recordId,
},
);
const additionalEmails =
eventPayload.properties.after.emails?.additionalEmails;
if (Array.isArray(additionalEmails)) {
const additionalEmailPromises = additionalEmails.map((email) =>
this.messageQueueService.add<CalendarEventParticipantMatchParticipantJobData>(
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
isPrimaryEmail: false,
personId: eventPayload.recordId,
},
),
);
jobPromises.push(...additionalEmailPromises);
}
await Promise.all(jobPromises);
}
}
@ -62,24 +91,106 @@ export class CalendarEventParticipantPersonListener {
eventPayload.properties.after,
).includes('emails')
) {
// TODO: modify this job to take an array of participants to match
await this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.emails?.primaryEmail,
personId: eventPayload.recordId,
},
if (!isDefined(eventPayload.properties.diff)) {
continue;
}
const jobPromises: Promise<void>[] = [];
if (hasPrimaryEmailChanged(eventPayload.properties.diff)) {
if (eventPayload.properties.before.emails?.primaryEmail) {
jobPromises.push(
this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.emails?.primaryEmail,
personId: eventPayload.recordId,
},
),
);
}
if (eventPayload.properties.after.emails?.primaryEmail) {
jobPromises.push(
this.messageQueueService.add<CalendarEventParticipantMatchParticipantJobData>(
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
isPrimaryEmail: true,
personId: eventPayload.recordId,
},
),
);
}
}
const { addedAdditionalEmails, removedAdditionalEmails } =
computeChangedAdditionalEmails(eventPayload.properties.diff);
const removedEmailPromises = removedAdditionalEmails.map((email) =>
this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
personId: eventPayload.recordId,
},
),
);
await this.messageQueueService.add<CalendarEventParticipantMatchParticipantJobData>(
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
personId: eventPayload.recordId,
},
const addedEmailPromises = addedAdditionalEmails.map((email) =>
this.messageQueueService.add<CalendarEventParticipantMatchParticipantJobData>(
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
isPrimaryEmail: false,
personId: eventPayload.recordId,
},
),
);
jobPromises.push(...removedEmailPromises, ...addedEmailPromises);
await Promise.all(jobPromises);
}
}
}
@OnDatabaseBatchEvent('person', DatabaseEventAction.DESTROYED)
async handleDestroyedEvent(
payload: WorkspaceEventBatch<
ObjectRecordDeleteEvent<PersonWorkspaceEntity>
>,
) {
for (const eventPayload of payload.events) {
await this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.emails?.primaryEmail,
personId: eventPayload.recordId,
},
);
const additionalEmails =
eventPayload.properties.before.emails?.additionalEmails;
if (Array.isArray(additionalEmails)) {
const additionalEmailPromises = additionalEmails.map((email) =>
this.messageQueueService.add<CalendarEventParticipantUnmatchParticipantJobData>(
CalendarEventParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
personId: eventPayload.recordId,
},
),
);
await Promise.all(additionalEmailPromises);
}
}
}

View File

@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { objectRecordChangedProperties as objectRecordUpdateEventChangedProperties } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-properties.util';
@ -16,8 +18,6 @@ import {
CalendarEventParticipantUnmatchParticipantJobData,
} from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-unmatch-participant.job';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class CalendarEventParticipantWorkspaceMemberListener {
@ -43,6 +43,7 @@ export class CalendarEventParticipantWorkspaceMemberListener {
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.userEmail,
workspaceMemberId: eventPayload.recordId,
isPrimaryEmail: true,
},
);
}
@ -76,6 +77,7 @@ export class CalendarEventParticipantWorkspaceMemberListener {
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.userEmail,
workspaceMemberId: eventPayload.recordId,
isPrimaryEmail: true,
},
);
}

View File

@ -110,10 +110,10 @@ export class CalendarEventParticipantService {
transactionManager,
);
await this.matchParticipantService.matchParticipants(
savedParticipants,
'calendarEventParticipant',
await this.matchParticipantService.matchParticipants({
participants: savedParticipants,
objectMetadataName: 'calendarEventParticipant',
transactionManager,
);
});
}
}

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;
};

View File

@ -0,0 +1,728 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { MatchParticipantService } from 'src/modules/match-participant/match-participant.service';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
describe('MatchParticipantService', () => {
let service: MatchParticipantService<MessageParticipantWorkspaceEntity>;
let twentyORMManager: TwentyORMManager;
let workspaceEventEmitter: WorkspaceEventEmitter;
let scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory;
let mockMessageParticipantRepository: {
find: jest.Mock;
update: jest.Mock;
createQueryBuilder: jest.Mock;
formatResult: jest.Mock;
};
let mockCalendarEventParticipantRepository: {
find: jest.Mock;
update: jest.Mock;
createQueryBuilder: jest.Mock;
formatResult: jest.Mock;
};
let mockPersonRepository: {
find: jest.Mock;
createQueryBuilder: jest.Mock;
formatResult: jest.Mock;
};
let mockWorkspaceMemberRepository: {
find: jest.Mock;
};
let mockTransactionManager: WorkspaceEntityManager;
const mockWorkspaceId = 'test-workspace-id';
beforeEach(async () => {
mockMessageParticipantRepository = {
find: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn(),
withDeleted: jest.fn().mockReturnThis(),
}),
formatResult: jest.fn(),
};
mockCalendarEventParticipantRepository = {
find: jest.fn(),
update: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn(),
withDeleted: jest.fn().mockReturnThis(),
}),
formatResult: jest.fn(),
};
mockPersonRepository = {
find: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn(),
withDeleted: jest.fn().mockReturnThis(),
}),
formatResult: jest.fn(),
};
mockWorkspaceMemberRepository = {
find: jest.fn(),
};
mockTransactionManager = {} as WorkspaceEntityManager;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatchParticipantService,
{
provide: TwentyORMManager,
useValue: {
getRepository: jest.fn().mockImplementation((entityName) => {
switch (entityName) {
case 'messageParticipant':
return mockMessageParticipantRepository;
case 'calendarEventParticipant':
return mockCalendarEventParticipantRepository;
case 'person':
return mockPersonRepository;
case 'workspaceMember':
return mockWorkspaceMemberRepository;
default:
return {};
}
}),
},
},
{
provide: WorkspaceEventEmitter,
useValue: {
emitCustomBatchEvent: jest.fn(),
},
},
{
provide: ScopedWorkspaceContextFactory,
useValue: {
create: jest.fn().mockReturnValue({
workspaceId: mockWorkspaceId,
}),
},
},
],
}).compile();
service = module.get<
MatchParticipantService<MessageParticipantWorkspaceEntity>
>(MatchParticipantService);
twentyORMManager = module.get<TwentyORMManager>(TwentyORMManager);
workspaceEventEmitter = module.get<WorkspaceEventEmitter>(
WorkspaceEventEmitter,
);
scopedWorkspaceContextFactory = module.get<ScopedWorkspaceContextFactory>(
ScopedWorkspaceContextFactory,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('matchParticipants', () => {
const mockParticipants = [
{
id: 'participant-1',
handle: 'test-1@example.com',
displayName: 'Test User',
},
{
id: 'participant-2',
handle: 'test-2@company.com',
displayName: 'Contact',
},
] as MessageParticipantWorkspaceEntity[];
const mockPeople = [
{
id: 'person-1',
emails: {
primaryEmail: 'test-1@example.com',
additionalEmails: ['test.alias@example.com'],
},
},
{
id: 'person-2',
emails: {
primaryEmail: 'test-2@company.com',
additionalEmails: ['test-2.alias@company.com'],
},
},
] as PersonWorkspaceEntity[];
const mockWorkspaceMembers = [
{
id: 'workspace-member-1',
userEmail: 'test-1@example.com',
},
] as WorkspaceMemberWorkspaceEntity[];
beforeEach(() => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(mockPeople),
withDeleted: jest.fn().mockReturnThis(),
};
mockPersonRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockPersonRepository.formatResult.mockResolvedValue(mockPeople);
mockWorkspaceMemberRepository.find.mockResolvedValue(
mockWorkspaceMembers,
);
mockMessageParticipantRepository.update.mockResolvedValue({
affected: 1,
});
mockMessageParticipantRepository.find.mockResolvedValue(mockParticipants);
});
it('should match participants with people by primary email', async () => {
await service.matchParticipants({
participants: mockParticipants,
objectMetadataName: 'messageParticipant',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
id: expect.any(Object),
handle: 'test-1@example.com',
},
{
personId: 'person-1',
workspaceMemberId: 'workspace-member-1',
},
undefined,
);
});
it('should match participants with people by additional email', async () => {
await service.matchParticipants({
participants: mockParticipants,
objectMetadataName: 'messageParticipant',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
id: expect.any(Object),
handle: 'test-2@company.com',
},
{
personId: 'person-2',
workspaceMemberId: undefined,
},
undefined,
);
});
it('should emit matched event after successful matching', async () => {
await service.matchParticipants({
participants: mockParticipants,
objectMetadataName: 'messageParticipant',
});
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
'messageParticipant_matched',
[
{
workspaceMemberId: null,
participants: mockParticipants,
},
],
mockWorkspaceId,
);
});
it('should work with calendar event participants', async () => {
const calendarParticipants = [
{
id: 'calendar-participant-1',
handle: 'test-1@example.com',
displayName: 'Test User',
isOrganizer: false,
responseStatus: 'ACCEPTED',
},
{
id: 'calendar-participant-2',
handle: 'test-2@company.com',
displayName: 'Contact',
isOrganizer: false,
responseStatus: 'ACCEPTED',
},
] as CalendarEventParticipantWorkspaceEntity[];
const calendarService =
new MatchParticipantService<CalendarEventParticipantWorkspaceEntity>(
workspaceEventEmitter,
twentyORMManager,
scopedWorkspaceContextFactory,
);
mockCalendarEventParticipantRepository.update.mockResolvedValue({
affected: 1,
});
mockCalendarEventParticipantRepository.find.mockResolvedValue(
calendarParticipants,
);
await calendarService.matchParticipants({
participants: calendarParticipants,
objectMetadataName: 'calendarEventParticipant',
});
expect(mockCalendarEventParticipantRepository.update).toHaveBeenCalled();
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
'calendarEventParticipant_matched',
expect.any(Array),
mockWorkspaceId,
);
});
it('should handle participants with no matching people or workspace members', async () => {
mockPersonRepository.formatResult.mockResolvedValue([]);
mockWorkspaceMemberRepository.find.mockResolvedValue([]);
await service.matchParticipants({
participants: mockParticipants,
objectMetadataName: 'messageParticipant',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
expect.any(Object),
{
personId: undefined,
workspaceMemberId: undefined,
},
undefined,
);
});
it('should throw error when workspace ID is not found', async () => {
scopedWorkspaceContextFactory.create = jest.fn().mockReturnValue({
workspaceId: null,
});
await expect(
service.matchParticipants({
participants: mockParticipants,
objectMetadataName: 'messageParticipant',
}),
).rejects.toThrow('Workspace ID is required');
});
it('should use transaction manager when provided', async () => {
await service.matchParticipants({
participants: mockParticipants,
objectMetadataName: 'messageParticipant',
transactionManager: mockTransactionManager,
});
expect(mockWorkspaceMemberRepository.find).toHaveBeenCalledWith(
expect.any(Object),
mockTransactionManager,
);
});
});
describe('matchParticipantsAfterPersonOrWorkspaceMemberCreation', () => {
const mockExistingParticipants = [
{
id: 'participant-1',
handle: 'test-1@example.com',
person: null,
},
{
id: 'participant-2',
handle: 'test-2@company.com',
person: {
id: 'existing-person',
emails: {
primaryEmail: 'test-2@company.com',
additionalEmails: ['test-2.alias@company.com'],
},
},
},
] as MessageParticipantWorkspaceEntity[];
beforeEach(() => {
mockMessageParticipantRepository.find.mockResolvedValue(
mockExistingParticipants,
);
mockMessageParticipantRepository.update.mockResolvedValue({
affected: 1,
});
});
describe('person matching', () => {
it('should match unmatched participants to new person', async () => {
await service.matchParticipantsAfterPersonCreation({
handle: 'test-1@example.com',
isPrimaryEmail: true,
objectMetadataName: 'messageParticipant',
personId: 'new-person-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
id: expect.any(Object),
},
{
person: {
id: 'new-person-id',
},
},
);
});
it('should re-match participants when new person has primary email and existing person has secondary', async () => {
await service.matchParticipantsAfterPersonCreation({
handle: 'test-2@company.com',
isPrimaryEmail: true,
objectMetadataName: 'messageParticipant',
personId: 'new-person-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
id: expect.any(Object),
},
{
person: {
id: 'new-person-id',
},
},
);
});
it('should not re-match when existing person has primary email', async () => {
const participantsWithPrimaryEmail = [
{
id: 'participant-1',
handle: 'test-1@example.com',
person: {
id: 'existing-person',
emails: {
primaryEmail: 'test-1@example.com',
additionalEmails: [],
},
},
},
] as MessageParticipantWorkspaceEntity[];
mockMessageParticipantRepository.find.mockResolvedValue(
participantsWithPrimaryEmail,
);
await service.matchParticipantsAfterPersonCreation({
handle: 'test-1@example.com',
isPrimaryEmail: false,
objectMetadataName: 'messageParticipant',
personId: 'new-person-id',
});
expect(mockMessageParticipantRepository.update).not.toHaveBeenCalled();
});
it('should not re-match when new email is secondary and existing person has secondary', async () => {
await service.matchParticipantsAfterPersonCreation({
handle: 'test-1@example.com',
isPrimaryEmail: false,
objectMetadataName: 'messageParticipant',
personId: 'new-person-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledTimes(
1,
);
});
it('should emit matched event when participants are updated', async () => {
const updatedParticipants = [mockExistingParticipants[0]];
mockMessageParticipantRepository.find
.mockResolvedValueOnce(mockExistingParticipants)
.mockResolvedValueOnce(updatedParticipants);
await service.matchParticipantsAfterPersonCreation({
handle: 'test-1@example.com',
isPrimaryEmail: true,
objectMetadataName: 'messageParticipant',
personId: 'new-person-id',
});
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
'messageParticipant_matched',
[
{
workspaceId: mockWorkspaceId,
name: 'messageParticipant_matched',
workspaceMemberId: null,
participants: updatedParticipants,
},
],
mockWorkspaceId,
);
});
});
describe('workspace member matching', () => {
it('should match all participants to workspace member', async () => {
await service.matchParticipantsAfterWorkspaceMemberCreation({
handle: 'test-1@example.com',
objectMetadataName: 'messageParticipant',
workspaceMemberId: 'workspace-member-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
id: expect.any(Object),
},
{
workspaceMember: {
id: 'workspace-member-id',
},
},
);
});
});
it('should throw error when workspace ID is not found', async () => {
scopedWorkspaceContextFactory.create = jest.fn().mockReturnValue({
workspaceId: null,
});
await expect(
service.matchParticipantsAfterPersonCreation({
handle: 'test-1@example.com',
isPrimaryEmail: true,
objectMetadataName: 'messageParticipant',
personId: 'person-id',
}),
).rejects.toThrow('Workspace ID is required');
});
});
describe('unmatchParticipants', () => {
beforeEach(() => {
mockMessageParticipantRepository.update.mockResolvedValue({
affected: 1,
});
mockMessageParticipantRepository.find.mockResolvedValue([]);
mockPersonRepository.formatResult.mockResolvedValue([]);
});
describe('person unmatching', () => {
it('should unmatch participants from person', async () => {
await service.unmatchParticipants({
handle: 'test-1@example.com',
objectMetadataName: 'messageParticipant',
personId: 'person-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
handle: expect.any(Object),
},
{
person: null,
},
);
});
it('should re-match to next best person after unmatching', async () => {
const mockAlternativePeople = [
{
id: 'alternative-person',
emails: {
primaryEmail: 'test-1@example.com',
additionalEmails: [],
},
},
] as PersonWorkspaceEntity[];
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(mockAlternativePeople),
withDeleted: jest.fn().mockReturnThis(),
};
mockPersonRepository.createQueryBuilder.mockReturnValue(
mockQueryBuilder,
);
mockPersonRepository.formatResult.mockResolvedValue(
mockAlternativePeople,
);
const rematchedParticipants = [
{
id: 'participant-1',
handle: 'test-1@example.com',
},
] as MessageParticipantWorkspaceEntity[];
mockMessageParticipantRepository.find.mockResolvedValue(
rematchedParticipants,
);
await service.unmatchParticipants({
handle: 'test-1@example.com',
objectMetadataName: 'messageParticipant',
personId: 'old-person-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
handle: expect.any(Object),
},
{
person: null,
},
);
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
handle: expect.any(Object),
},
{
personId: 'alternative-person',
},
);
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
'messageParticipant_matched',
[
{
workspaceMemberId: null,
participants: rematchedParticipants,
},
],
mockWorkspaceId,
);
});
it('should not re-match when no alternative people found', async () => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
withDeleted: jest.fn().mockReturnThis(),
};
mockPersonRepository.createQueryBuilder.mockReturnValue(
mockQueryBuilder,
);
mockPersonRepository.formatResult.mockResolvedValue([]);
await service.unmatchParticipants({
handle: 'test-1@example.com',
objectMetadataName: 'messageParticipant',
personId: 'person-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledTimes(
1,
);
expect(
workspaceEventEmitter.emitCustomBatchEvent,
).not.toHaveBeenCalled();
});
});
describe('workspace member unmatching', () => {
it('should unmatch participants from workspace member', async () => {
await service.unmatchParticipants({
handle: 'test-1@example.com',
objectMetadataName: 'messageParticipant',
workspaceMemberId: 'workspace-member-id',
});
expect(mockMessageParticipantRepository.update).toHaveBeenCalledWith(
{
handle: expect.any(Object),
},
{
workspaceMember: null,
},
);
});
});
it('should throw error when workspace ID is not found', async () => {
scopedWorkspaceContextFactory.create = jest.fn().mockReturnValue({
workspaceId: null,
});
await expect(
service.unmatchParticipants({
handle: 'test-1@example.com',
objectMetadataName: 'messageParticipant',
personId: 'person-id',
}),
).rejects.toThrow('Workspace ID is required');
});
});
describe('getParticipantRepository', () => {
it('should return message participant repository for messageParticipant', async () => {
const repository = await (service as any).getParticipantRepository(
'messageParticipant',
);
expect(twentyORMManager.getRepository).toHaveBeenCalledWith(
'messageParticipant',
);
expect(repository).toBe(mockMessageParticipantRepository);
});
it('should return calendar event participant repository for calendarEventParticipant', async () => {
const repository = await (service as any).getParticipantRepository(
'calendarEventParticipant',
);
expect(twentyORMManager.getRepository).toHaveBeenCalledWith(
'calendarEventParticipant',
);
expect(repository).toBe(mockCalendarEventParticipantRepository);
});
});
});

View File

@ -7,6 +7,8 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { addPersonEmailFiltersToQueryBuilder } from 'src/modules/match-participant/utils/add-person-email-filters-to-query-builder';
import { findPersonByPrimaryOrAdditionalEmail } from 'src/modules/match-participant/utils/find-person-by-primary-or-additional-email';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@ -37,11 +39,19 @@ export class MatchParticipantService<
);
}
public async matchParticipants(
participants: ParticipantWorkspaceEntity[],
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant',
transactionManager?: WorkspaceEntityManager,
) {
public async matchParticipants({
participants,
objectMetadataName,
transactionManager,
}: {
participants: ParticipantWorkspaceEntity[];
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant';
transactionManager?: WorkspaceEntityManager;
}) {
if (participants.length === 0) {
return;
}
const participantRepository =
await this.getParticipantRepository(objectMetadataName);
@ -61,14 +71,16 @@ export class MatchParticipantService<
'person',
);
const people = await personRepository.find(
{
where: {
emails: Any(uniqueParticipantsHandles),
},
},
transactionManager,
);
const queryBuilder = addPersonEmailFiltersToQueryBuilder({
queryBuilder: personRepository.createQueryBuilder('person'),
emails: uniqueParticipantsHandles,
});
const rawPeople = await queryBuilder
.orderBy('person.createdAt', 'ASC')
.getMany();
const people = await personRepository.formatResult(rawPeople);
const workspaceMemberRepository =
await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>(
@ -85,9 +97,10 @@ export class MatchParticipantService<
);
for (const handle of uniqueParticipantsHandles) {
const person = people.find(
(person) => person.emails?.primaryEmail === handle,
);
const person = findPersonByPrimaryOrAdditionalEmail({
people,
email: handle,
});
const workspaceMember = workspaceMembers.find(
(workspaceMember) => workspaceMember.userEmail === handle,
@ -128,12 +141,17 @@ export class MatchParticipantService<
);
}
public async matchParticipantsAfterPersonOrWorkspaceMemberCreation(
handle: string,
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant',
personId?: string,
workspaceMemberId?: string,
) {
public async unmatchParticipants({
handle,
objectMetadataName,
personId,
workspaceMemberId,
}: {
handle: string;
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant';
personId?: string;
workspaceMemberId?: string;
}) {
const participantRepository =
await this.getParticipantRepository(objectMetadataName);
@ -143,20 +161,141 @@ export class MatchParticipantService<
throw new Error('Workspace ID is required');
}
if (personId) {
await participantRepository.update(
{
handle: Equal(handle),
},
{
person: null,
},
);
const personRepository =
await this.twentyORMManager.getRepository<PersonWorkspaceEntity>(
'person',
);
const queryBuilder = addPersonEmailFiltersToQueryBuilder({
queryBuilder: personRepository.createQueryBuilder('person'),
emails: [handle],
excludePersonIds: [personId],
});
const rawPeople = await queryBuilder
.orderBy('person.createdAt', 'ASC')
.getMany();
const peopleToMatch = await personRepository.formatResult(rawPeople);
if (peopleToMatch.length > 0) {
const bestMatch = findPersonByPrimaryOrAdditionalEmail({
people: peopleToMatch,
email: handle,
});
if (bestMatch) {
await participantRepository.update(
{
handle: Equal(handle),
},
{
personId: bestMatch.id,
},
);
const rematchedParticipants = await participantRepository.find({
where: {
handle: Equal(handle),
},
});
this.workspaceEventEmitter.emitCustomBatchEvent(
`${objectMetadataName}_matched`,
[
{
workspaceMemberId: null,
participants: rematchedParticipants,
},
],
workspaceId,
);
}
}
}
if (workspaceMemberId) {
await participantRepository.update(
{
handle: Equal(handle),
},
{
workspaceMember: null,
},
);
}
}
public async matchParticipantsAfterPersonCreation({
handle,
isPrimaryEmail,
personId,
objectMetadataName,
}: {
handle: string;
isPrimaryEmail: boolean;
personId: string;
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant';
}) {
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) {
throw new Error('Workspace ID is required');
}
const participantRepository =
await this.getParticipantRepository(objectMetadataName);
const participantsToUpdate = await participantRepository.find({
where: {
handle: Equal(handle),
},
relations: ['person'],
});
const participantIdsToUpdate = participantsToUpdate.map(
(participant) => participant.id,
);
const participantIdsToMatchWithPerson: string[] = [];
if (personId) {
for (const participant of participantsToUpdate) {
const existingPerson = participant.person;
if (!existingPerson) {
participantIdsToMatchWithPerson.push(participant.id);
continue;
}
const isAssociatedToPrimaryEmail =
existingPerson.emails?.primaryEmail.toLowerCase() ===
handle.toLowerCase();
if (isAssociatedToPrimaryEmail) {
continue;
}
const isAssociatedToSecondaryEmail =
Array.isArray(existingPerson.emails?.additionalEmails) &&
existingPerson.emails.additionalEmails.some(
(email) => email.toLowerCase() === handle.toLowerCase(),
);
if (isAssociatedToSecondaryEmail && isPrimaryEmail) {
participantIdsToMatchWithPerson.push(participant.id);
}
}
if (participantIdsToMatchWithPerson.length > 0) {
await participantRepository.update(
{
id: Any(participantIdsToUpdate),
id: Any(participantIdsToMatchWithPerson),
},
{
person: {
@ -167,7 +306,7 @@ export class MatchParticipantService<
const updatedParticipants = await participantRepository.find({
where: {
id: Any(participantIdsToUpdate),
id: Any(participantIdsToMatchWithPerson),
},
});
@ -184,49 +323,45 @@ export class MatchParticipantService<
workspaceId,
);
}
if (workspaceMemberId) {
await participantRepository.update(
{
id: Any(participantIdsToUpdate),
},
{
workspaceMember: {
id: workspaceMemberId,
},
},
);
}
}
public async unmatchParticipants(
handle: string,
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant',
personId?: string,
workspaceMemberId?: string,
) {
public async matchParticipantsAfterWorkspaceMemberCreation({
handle,
workspaceMemberId,
objectMetadataName,
}: {
handle: string;
workspaceMemberId: string;
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant';
}) {
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) {
throw new Error('Workspace ID is required');
}
const participantRepository =
await this.getParticipantRepository(objectMetadataName);
if (personId) {
await participantRepository.update(
{
handle: Equal(handle),
const participantsToUpdate = await participantRepository.find({
where: {
handle: Equal(handle),
},
});
const participantIdsToMatchWithWorkspaceMember = participantsToUpdate.map(
(participant) => participant.id,
);
await participantRepository.update(
{
id: Any(participantIdsToMatchWithWorkspaceMember),
},
{
workspaceMember: {
id: workspaceMemberId,
},
{
person: null,
},
);
}
if (workspaceMemberId) {
await participantRepository.update(
{
handle: Equal(handle),
},
{
workspaceMember: null,
},
);
}
},
);
}
}

View File

@ -0,0 +1,385 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`addPersonEmailFiltersToQueryBuilder case-insensitive email normalization: should normalize emails to lowercase - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"test@example.com",
"contact@company.com",
],
},
],
"method": "where",
},
{
"args": [
"person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["test@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [
"person.emailsAdditionalEmails @> :email1::jsonb",
{
"email1": "["contact@company.com"]",
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder emails with empty exclusion array: should handle empty exclusions array - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"test@example.com",
],
},
],
"method": "where",
},
{
"args": [
"person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["test@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder empty emails array: should handle empty email array gracefully - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [],
},
],
"method": "where",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder multiple emails with exclusions: should handle exclusions with multiple emails - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"test@example.com",
"contact@company.com",
],
},
],
"method": "where",
},
{
"args": [
"person.id NOT IN (:...excludePersonIds)",
{
"excludePersonIds": [
"person-1",
],
},
],
"method": "andWhere",
},
{
"args": [
"person.id NOT IN (:...excludePersonIds) AND person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["test@example.com"]",
"excludePersonIds": [
"person-1",
],
},
],
"method": "orWhere",
},
{
"args": [
"person.id NOT IN (:...excludePersonIds) AND person.emailsAdditionalEmails @> :email1::jsonb",
{
"email1": "["contact@company.com"]",
"excludePersonIds": [
"person-1",
],
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder multiple emails without exclusions: should handle multiple email addresses correctly - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"test@example.com",
"contact@company.com",
],
},
],
"method": "where",
},
{
"args": [
"person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["test@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [
"person.emailsAdditionalEmails @> :email1::jsonb",
{
"email1": "["contact@company.com"]",
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder single email with person ID exclusions: should handle exclusions with a single email - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"test@example.com",
],
},
],
"method": "where",
},
{
"args": [
"person.id NOT IN (:...excludePersonIds)",
{
"excludePersonIds": [
"person-1",
"person-2",
],
},
],
"method": "andWhere",
},
{
"args": [
"person.id NOT IN (:...excludePersonIds) AND person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["test@example.com"]",
"excludePersonIds": [
"person-1",
"person-2",
],
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder single email without exclusions: should handle a single email address correctly - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"test@example.com",
],
},
],
"method": "where",
},
{
"args": [
"person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["test@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;
exports[`addPersonEmailFiltersToQueryBuilder three emails with unique parameter generation: should create unique parameter names for each email - query builder calls 1`] = `
[
{
"args": [
[
"person.id",
"person.emailsPrimaryEmail",
"person.emailsAdditionalEmails",
],
],
"method": "select",
},
{
"args": [
"LOWER(person.emailsPrimaryEmail) IN (:...emails)",
{
"emails": [
"email1@example.com",
"email2@example.com",
"email3@example.com",
],
},
],
"method": "where",
},
{
"args": [
"person.emailsAdditionalEmails @> :email0::jsonb",
{
"email0": "["email1@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [
"person.emailsAdditionalEmails @> :email1::jsonb",
{
"email1": "["email2@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [
"person.emailsAdditionalEmails @> :email2::jsonb",
{
"email2": "["email3@example.com"]",
},
],
"method": "orWhere",
},
{
"args": [],
"method": "withDeleted",
},
]
`;

View File

@ -0,0 +1,137 @@
import { EachTestingContext } from 'twenty-shared/testing';
import { SelectQueryBuilder } from 'typeorm';
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';
type AddPersonEmailFiltersToQueryBuilderTestCase = EachTestingContext<{
emails: string[];
excludePersonIds?: string[];
description: string;
}>;
const testCases: AddPersonEmailFiltersToQueryBuilderTestCase[] = [
{
title: 'single email without exclusions',
context: {
emails: ['test@example.com'],
description: 'should handle a single email address correctly',
},
},
{
title: 'multiple emails without exclusions',
context: {
emails: ['test@example.com', 'contact@company.com'],
description: 'should handle multiple email addresses correctly',
},
},
{
title: 'single email with person ID exclusions',
context: {
emails: ['test@example.com'],
excludePersonIds: ['person-1', 'person-2'],
description: 'should handle exclusions with a single email',
},
},
{
title: 'multiple emails with exclusions',
context: {
emails: ['test@example.com', 'contact@company.com'],
excludePersonIds: ['person-1'],
description: 'should handle exclusions with multiple emails',
},
},
{
title: 'empty emails array',
context: {
emails: [],
description: 'should handle empty email array gracefully',
},
},
{
title: 'emails with empty exclusion array',
context: {
emails: ['test@example.com'],
excludePersonIds: [],
description: 'should handle empty exclusions array',
},
},
{
title: 'three emails with unique parameter generation',
context: {
emails: [
'email1@example.com',
'email2@example.com',
'email3@example.com',
],
description: 'should create unique parameter names for each email',
},
},
{
title: 'case-insensitive email normalization',
context: {
emails: ['Test@Example.COM', 'CONTACT@Company.com'],
description: 'should normalize emails to lowercase',
},
},
];
interface QueryBuilderCall {
method: string;
args: any[];
}
let queryBuilderCalls: QueryBuilderCall[] = [];
const mockQueryBuilder: Partial<SelectQueryBuilder<PersonWorkspaceEntity>> = {
select: jest.fn().mockImplementation((...args) => {
queryBuilderCalls.push({ method: 'select', args });
return mockQueryBuilder;
}),
where: jest.fn().mockImplementation((...args) => {
queryBuilderCalls.push({ method: 'where', args });
return mockQueryBuilder;
}),
andWhere: jest.fn().mockImplementation((...args) => {
queryBuilderCalls.push({ method: 'andWhere', args });
return mockQueryBuilder;
}),
orWhere: jest.fn().mockImplementation((...args) => {
queryBuilderCalls.push({ method: 'orWhere', args });
return mockQueryBuilder;
}),
withDeleted: jest.fn().mockImplementation((...args) => {
queryBuilderCalls.push({ method: 'withDeleted', args });
return mockQueryBuilder;
}),
};
describe('addPersonEmailFiltersToQueryBuilder', () => {
beforeEach(() => {
queryBuilderCalls = [];
jest.clearAllMocks();
});
it.each(testCases)(
'$title',
({ context: { emails, excludePersonIds, description } }) => {
const result = addPersonEmailFiltersToQueryBuilder({
queryBuilder:
mockQueryBuilder as SelectQueryBuilder<PersonWorkspaceEntity>,
emails,
excludePersonIds,
});
expect(queryBuilderCalls).toMatchSnapshot(
`${description} - query builder calls`,
);
expect(result).toBe(mockQueryBuilder);
},
);
});

View File

@ -0,0 +1,184 @@
import { findPersonByPrimaryOrAdditionalEmail } from 'src/modules/match-participant/utils/find-person-by-primary-or-additional-email';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
describe('findPersonByPrimaryOrAdditionalEmail', () => {
const mockPeople = [
{
id: 'person-1',
emails: {
primaryEmail: 'primary@example.com',
additionalEmails: [
'additional1@example.com',
'additional2@example.com',
],
},
},
{
id: 'person-2',
emails: {
primaryEmail: 'other@example.com',
additionalEmails: ['test@example.com'],
},
},
{
id: 'person-3',
emails: {
primaryEmail: 'test@example.com',
additionalEmails: ['backup@example.com'],
},
},
] as PersonWorkspaceEntity[];
it('should return person with matching primary email', () => {
const result = findPersonByPrimaryOrAdditionalEmail({
people: mockPeople,
email: 'test@example.com',
});
expect(result).toEqual(mockPeople[2]);
});
it('should return person with matching additional email when no primary match exists', () => {
const result = findPersonByPrimaryOrAdditionalEmail({
people: mockPeople,
email: 'additional1@example.com',
});
expect(result).toEqual(mockPeople[0]);
});
it('should prioritize primary email over additional email', () => {
const peopleWithConflict = [
{
id: 'person-with-additional',
emails: {
primaryEmail: 'other@example.com',
additionalEmails: ['conflict@example.com'],
},
},
{
id: 'person-with-primary',
emails: {
primaryEmail: 'conflict@example.com',
additionalEmails: ['backup@example.com'],
},
},
] as PersonWorkspaceEntity[];
const result = findPersonByPrimaryOrAdditionalEmail({
people: peopleWithConflict,
email: 'conflict@example.com',
});
expect(result).toEqual(peopleWithConflict[1]);
});
it('should return undefined when no match is found', () => {
const result = findPersonByPrimaryOrAdditionalEmail({
people: mockPeople,
email: 'nonexistent@example.com',
});
expect(result).toBeUndefined();
});
it('should return undefined when people array is empty', () => {
const result = findPersonByPrimaryOrAdditionalEmail({
people: [],
email: 'test@example.com',
});
expect(result).toBeUndefined();
});
it('should handle people with null or undefined emails', () => {
const peopleWithNullEmails = [
{
id: 'person-1',
emails: null,
},
{
id: 'person-2',
emails: {
primaryEmail: 'test@example.com',
additionalEmails: null,
},
},
{
id: 'person-3',
emails: {
primaryEmail: null,
additionalEmails: ['test@example.com'],
},
},
] as PersonWorkspaceEntity[];
const result = findPersonByPrimaryOrAdditionalEmail({
people: peopleWithNullEmails,
email: 'test@example.com',
});
expect(result).toEqual(peopleWithNullEmails[1]);
});
it('should handle people with empty additional emails array', () => {
const peopleWithEmptyAdditional = [
{
id: 'person-1',
emails: {
primaryEmail: 'other@example.com',
additionalEmails: [],
},
},
{
id: 'person-2',
emails: {
primaryEmail: 'test@example.com',
additionalEmails: [],
},
},
] as PersonWorkspaceEntity[];
const result = findPersonByPrimaryOrAdditionalEmail({
people: peopleWithEmptyAdditional,
email: 'test@example.com',
});
expect(result).toEqual(peopleWithEmptyAdditional[1]);
});
it('should handle case sensitivity correctly', () => {
const result = findPersonByPrimaryOrAdditionalEmail({
people: mockPeople,
email: 'TEST@EXAMPLE.COM',
});
expect(result).toEqual(mockPeople[2]);
});
it('should handle people with non-array additional emails', () => {
const peopleWithInvalidAdditionalEmail = [
{
id: 'person-1',
emails: {
primaryEmail: 'other@example.com',
additionalEmails: 'not-an-array' as any,
},
},
{
id: 'person-2',
emails: {
primaryEmail: 'test@example.com',
additionalEmails: [],
},
},
] as PersonWorkspaceEntity[];
const result = findPersonByPrimaryOrAdditionalEmail({
people: peopleWithInvalidAdditionalEmail,
email: 'test@example.com',
});
expect(result).toEqual(peopleWithInvalidAdditionalEmail[1]);
});
});

View File

@ -0,0 +1,63 @@
import { SelectQueryBuilder } from 'typeorm';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
export interface AddPersonEmailFiltersToQueryBuilderOptions {
queryBuilder: SelectQueryBuilder<PersonWorkspaceEntity>;
emails: string[];
excludePersonIds?: string[];
}
/**
* Adds filters to a query builder to only return people with the given emails.
* This is used to find people by their primary or additional emails.
* We use the query builder here instead of the find method from typeorm
* because we need to use the jsonb @> operator to check if the email is in the additional emails array.
*
* @param queryBuilder - The query builder to add the filters to
* @param emails - The emails to filter by
* @param excludePersonIds - The person IDs to exclude from the results
*/
export function addPersonEmailFiltersToQueryBuilder({
queryBuilder,
emails,
excludePersonIds = [],
}: AddPersonEmailFiltersToQueryBuilderOptions): SelectQueryBuilder<PersonWorkspaceEntity> {
const normalizedEmails = emails.map((email) => email.toLowerCase());
queryBuilder = queryBuilder
.select([
'person.id',
'person.emailsPrimaryEmail',
'person.emailsAdditionalEmails',
])
.where('LOWER(person.emailsPrimaryEmail) IN (:...emails)', {
emails: normalizedEmails,
});
if (excludePersonIds.length > 0) {
queryBuilder = queryBuilder.andWhere(
'person.id NOT IN (:...excludePersonIds)',
{
excludePersonIds,
},
);
}
for (const [index, email] of normalizedEmails.entries()) {
const emailParamName = `email${index}`;
const orWhereIsInAdditionalEmail =
excludePersonIds.length > 0
? `person.id NOT IN (:...excludePersonIds) AND person.emailsAdditionalEmails @> :${emailParamName}::jsonb`
: `person.emailsAdditionalEmails @> :${emailParamName}::jsonb`;
queryBuilder = queryBuilder.orWhere(orWhereIsInAdditionalEmail, {
...(excludePersonIds.length > 0 && { excludePersonIds }),
[emailParamName]: JSON.stringify([email]),
});
}
queryBuilder = queryBuilder.withDeleted();
return queryBuilder;
}

View File

@ -0,0 +1,33 @@
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
export const findPersonByPrimaryOrAdditionalEmail = ({
people,
email,
}: {
people: PersonWorkspaceEntity[];
email: string;
}): PersonWorkspaceEntity | undefined => {
const lowercaseEmail = email.toLowerCase();
const personWithPrimaryEmail = people.find(
(person) => person.emails?.primaryEmail?.toLowerCase() === lowercaseEmail,
);
if (personWithPrimaryEmail) {
return personWithPrimaryEmail;
}
const personWithAdditionalEmail = people.find((person) => {
const additionalEmails = person.emails?.additionalEmails;
if (!Array.isArray(additionalEmails)) {
return false;
}
return additionalEmails.some(
(additionalEmail) => additionalEmail.toLowerCase() === lowercaseEmail,
);
});
return personWithAdditionalEmail;
};

View File

@ -8,6 +8,7 @@ import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/
export type MessageParticipantMatchParticipantJobData = {
workspaceId: string;
isPrimaryEmail: boolean;
email: string;
personId?: string;
workspaceMemberId?: string;
@ -24,13 +25,25 @@ export class MessageParticipantMatchParticipantJob {
@Process(MessageParticipantMatchParticipantJob.name)
async handle(data: MessageParticipantMatchParticipantJobData): Promise<void> {
const { email, personId, workspaceMemberId } = data;
const { isPrimaryEmail, email, personId, workspaceMemberId } = data;
await this.matchParticipantService.matchParticipantsAfterPersonOrWorkspaceMemberCreation(
email,
'messageParticipant',
personId,
workspaceMemberId,
);
if (personId) {
await this.matchParticipantService.matchParticipantsAfterPersonCreation({
handle: email,
isPrimaryEmail,
objectMetadataName: 'messageParticipant',
personId,
});
}
if (workspaceMemberId) {
await this.matchParticipantService.matchParticipantsAfterWorkspaceMemberCreation(
{
handle: email,
objectMetadataName: 'messageParticipant',
workspaceMemberId,
},
);
}
}
}

View File

@ -28,11 +28,11 @@ export class MessageParticipantUnmatchParticipantJob {
): Promise<void> {
const { email, personId, workspaceMemberId } = data;
await this.matchParticipantService.unmatchParticipants(
email,
'messageParticipant',
await this.matchParticipantService.unmatchParticipants({
handle: email,
objectMetadataName: 'messageParticipant',
personId,
workspaceMemberId,
);
});
}
}

View File

@ -1,14 +1,19 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event';
import { objectRecordChangedProperties as objectRecordUpdateEventChangedProperties } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-properties.util';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type';
import { computeChangedAdditionalEmails } from 'src/modules/contact-creation-manager/utils/compute-changed-additional-emails';
import { hasPrimaryEmailChanged } from 'src/modules/contact-creation-manager/utils/has-primary-email-changed';
import {
MessageParticipantMatchParticipantJob,
MessageParticipantMatchParticipantJobData,
@ -33,18 +38,42 @@ export class MessageParticipantPersonListener {
>,
) {
for (const eventPayload of payload.events) {
if (!eventPayload.properties.after.emails?.primaryEmail) {
continue;
const jobPromises: Promise<void>[] = [];
if (isDefined(eventPayload.properties.after.emails?.primaryEmail)) {
jobPromises.push(
this.messageQueueService.add<MessageParticipantMatchParticipantJobData>(
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
isPrimaryEmail: true,
personId: eventPayload.recordId,
},
),
);
}
await this.messageQueueService.add<MessageParticipantMatchParticipantJobData>(
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
personId: eventPayload.recordId,
},
);
const additionalEmails =
eventPayload.properties.after.emails?.additionalEmails;
if (Array.isArray(additionalEmails)) {
const additionalEmailPromises = additionalEmails.map((email) =>
this.messageQueueService.add<MessageParticipantMatchParticipantJobData>(
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
isPrimaryEmail: false,
personId: eventPayload.recordId,
},
),
);
jobPromises.push(...additionalEmailPromises);
}
await Promise.all(jobPromises);
}
}
@ -61,23 +90,106 @@ export class MessageParticipantPersonListener {
eventPayload.properties.after,
).includes('emails')
) {
await this.messageQueueService.add<MessageParticipantUnmatchParticipantJobData>(
MessageParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.emails?.primaryEmail,
personId: eventPayload.recordId,
},
if (!isDefined(eventPayload.properties.diff)) {
continue;
}
const jobPromises: Promise<void>[] = [];
if (hasPrimaryEmailChanged(eventPayload.properties.diff)) {
if (eventPayload.properties.before.emails?.primaryEmail) {
jobPromises.push(
this.messageQueueService.add<MessageParticipantUnmatchParticipantJobData>(
MessageParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.emails?.primaryEmail,
personId: eventPayload.recordId,
},
),
);
}
if (eventPayload.properties.after.emails?.primaryEmail) {
jobPromises.push(
this.messageQueueService.add<MessageParticipantMatchParticipantJobData>(
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
isPrimaryEmail: true,
personId: eventPayload.recordId,
},
),
);
}
}
const { addedAdditionalEmails, removedAdditionalEmails } =
computeChangedAdditionalEmails(eventPayload.properties.diff);
const removedEmailPromises = removedAdditionalEmails.map((email) =>
this.messageQueueService.add<MessageParticipantUnmatchParticipantJobData>(
MessageParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
personId: eventPayload.recordId,
},
),
);
await this.messageQueueService.add<MessageParticipantMatchParticipantJobData>(
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.emails?.primaryEmail,
personId: eventPayload.recordId,
},
const addedEmailPromises = addedAdditionalEmails.map((email) =>
this.messageQueueService.add<MessageParticipantMatchParticipantJobData>(
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
isPrimaryEmail: false,
personId: eventPayload.recordId,
},
),
);
jobPromises.push(...removedEmailPromises, ...addedEmailPromises);
await Promise.all(jobPromises);
}
}
}
@OnDatabaseBatchEvent('person', DatabaseEventAction.DESTROYED)
async handleDestroyedEvent(
payload: WorkspaceEventBatch<
ObjectRecordDeleteEvent<PersonWorkspaceEntity>
>,
) {
for (const eventPayload of payload.events) {
await this.messageQueueService.add<MessageParticipantUnmatchParticipantJobData>(
MessageParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.emails?.primaryEmail,
personId: eventPayload.recordId,
},
);
const additionalEmails =
eventPayload.properties.before.emails?.additionalEmails;
if (Array.isArray(additionalEmails)) {
const additionalEmailPromises = additionalEmails.map((email) =>
this.messageQueueService.add<MessageParticipantUnmatchParticipantJobData>(
MessageParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: email,
personId: eventPayload.recordId,
},
),
);
await Promise.all(additionalEmailPromises);
}
}
}

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@ -61,6 +61,7 @@ export class MessageParticipantWorkspaceMemberListener {
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.userEmail,
workspaceMemberId: eventPayload.recordId,
isPrimaryEmail: true,
},
);
}
@ -94,6 +95,7 @@ export class MessageParticipantWorkspaceMemberListener {
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.userEmail,
workspaceMemberId: eventPayload.recordId,
isPrimaryEmail: true,
},
);
}

View File

@ -35,10 +35,10 @@ export class MessagingMessageParticipantService {
transactionManager,
);
await this.matchParticipantService.matchParticipants(
savedParticipants,
'messageParticipant',
await this.matchParticipantService.matchParticipants({
participants: savedParticipants,
objectMetadataName: 'messageParticipant',
transactionManager,
);
});
}
}