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