diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-match-participant.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-match-participant.job.ts index 134244814..3e249bfeb 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-match-participant.job.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-match-participant.job.ts @@ -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 { - 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, + }, + ); + } } } diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-unmatch-participant.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-unmatch-participant.job.ts index 9d3af1486..765e1286b 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-unmatch-participant.job.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-unmatch-participant.job.ts @@ -28,11 +28,11 @@ export class CalendarEventParticipantUnmatchParticipantJob { ): Promise { const { email, personId, workspaceMemberId } = data; - await this.matchParticipantService.unmatchParticipants( - email, - 'calendarEventParticipant', + await this.matchParticipantService.unmatchParticipants({ + handle: email, + objectMetadataName: 'calendarEventParticipant', personId, workspaceMemberId, - ); + }); } } diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-person.listener.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-person.listener.ts index 345e939bf..d0b413cad 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-person.listener.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-person.listener.ts @@ -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[] = []; + + if (isDefined(eventPayload.properties.after.emails?.primaryEmail)) { + // TODO: modify this job to take an array of participants to match + jobPromises.push( + this.messageQueueService.add( + 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( - 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( + 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( - CalendarEventParticipantUnmatchParticipantJob.name, - { - workspaceId: payload.workspaceId, - email: eventPayload.properties.before.emails?.primaryEmail, - personId: eventPayload.recordId, - }, + if (!isDefined(eventPayload.properties.diff)) { + continue; + } + + const jobPromises: Promise[] = []; + + if (hasPrimaryEmailChanged(eventPayload.properties.diff)) { + if (eventPayload.properties.before.emails?.primaryEmail) { + jobPromises.push( + this.messageQueueService.add( + 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( + 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( + CalendarEventParticipantUnmatchParticipantJob.name, + { + workspaceId: payload.workspaceId, + email: email, + personId: eventPayload.recordId, + }, + ), ); - await this.messageQueueService.add( - CalendarEventParticipantMatchParticipantJob.name, - { - workspaceId: payload.workspaceId, - email: eventPayload.properties.after.emails?.primaryEmail, - personId: eventPayload.recordId, - }, + const addedEmailPromises = addedAdditionalEmails.map((email) => + this.messageQueueService.add( + 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 + >, + ) { + for (const eventPayload of payload.events) { + await this.messageQueueService.add( + 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( + CalendarEventParticipantUnmatchParticipantJob.name, + { + workspaceId: payload.workspaceId, + email: email, + personId: eventPayload.recordId, + }, + ), + ); + + await Promise.all(additionalEmailPromises); } } } diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-workspace-member.listener.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-workspace-member.listener.ts index 74c06fff8..8792bc124 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-workspace-member.listener.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-workspace-member.listener.ts @@ -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, }, ); } diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/services/calendar-event-participant.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/services/calendar-event-participant.service.ts index bb190882d..19a9540b4 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/services/calendar-event-participant.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/services/calendar-event-participant.service.ts @@ -110,10 +110,10 @@ export class CalendarEventParticipantService { transactionManager, ); - await this.matchParticipantService.matchParticipants( - savedParticipants, - 'calendarEventParticipant', + await this.matchParticipantService.matchParticipants({ + participants: savedParticipants, + objectMetadataName: 'calendarEventParticipant', transactionManager, - ); + }); } } diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts index 57a6cec8c..b4e6ea703 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company-and-contact.service.ts @@ -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((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( diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/compute-changed-additional-emails.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/compute-changed-additional-emails.spec.ts new file mode 100644 index 000000000..68dcd44d8 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/compute-changed-additional-emails.spec.ts @@ -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>; + 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); + }); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/has-primary-email-changed.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/has-primary-email-changed.spec.ts new file mode 100644 index 000000000..8b4ed3fbb --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/__tests__/has-primary-email-changed.spec.ts @@ -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>; + 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); + }); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/compute-changed-additional-emails.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/compute-changed-additional-emails.ts new file mode 100644 index 000000000..c8269be5a --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/compute-changed-additional-emails.ts @@ -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>, +) => { + 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, + }; +}; diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/has-primary-email-changed.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/has-primary-email-changed.ts new file mode 100644 index 000000000..7f7802004 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/has-primary-email-changed.ts @@ -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>, +) => { + const before = diff.emails?.before?.primaryEmail?.toLowerCase(); + const after = diff.emails?.after?.primaryEmail?.toLowerCase(); + + return before !== after; +}; diff --git a/packages/twenty-server/src/modules/match-participant/match-participant.service.spec.ts b/packages/twenty-server/src/modules/match-participant/match-participant.service.spec.ts new file mode 100644 index 000000000..47817c9a0 --- /dev/null +++ b/packages/twenty-server/src/modules/match-participant/match-participant.service.spec.ts @@ -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; + 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 + >(MatchParticipantService); + twentyORMManager = module.get(TwentyORMManager); + workspaceEventEmitter = module.get( + WorkspaceEventEmitter, + ); + scopedWorkspaceContextFactory = module.get( + 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( + 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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/match-participant/match-participant.service.ts b/packages/twenty-server/src/modules/match-participant/match-participant.service.ts index 93184dd58..9e453b672 100644 --- a/packages/twenty-server/src/modules/match-participant/match-participant.service.ts +++ b/packages/twenty-server/src/modules/match-participant/match-participant.service.ts @@ -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( @@ -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( + '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, - }, - ); - } + }, + ); } } diff --git a/packages/twenty-server/src/modules/match-participant/utils/__tests__/__snapshots__/add-person-email-filters-to-query-builder.util.spec.ts.snap b/packages/twenty-server/src/modules/match-participant/utils/__tests__/__snapshots__/add-person-email-filters-to-query-builder.util.spec.ts.snap new file mode 100644 index 000000000..361f7e610 --- /dev/null +++ b/packages/twenty-server/src/modules/match-participant/utils/__tests__/__snapshots__/add-person-email-filters-to-query-builder.util.spec.ts.snap @@ -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", + }, +] +`; diff --git a/packages/twenty-server/src/modules/match-participant/utils/__tests__/add-person-email-filters-to-query-builder.util.spec.ts b/packages/twenty-server/src/modules/match-participant/utils/__tests__/add-person-email-filters-to-query-builder.util.spec.ts new file mode 100644 index 000000000..ea9d13913 --- /dev/null +++ b/packages/twenty-server/src/modules/match-participant/utils/__tests__/add-person-email-filters-to-query-builder.util.spec.ts @@ -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> = { + 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, + emails, + excludePersonIds, + }); + + expect(queryBuilderCalls).toMatchSnapshot( + `${description} - query builder calls`, + ); + + expect(result).toBe(mockQueryBuilder); + }, + ); +}); diff --git a/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts b/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts new file mode 100644 index 000000000..3c4fcbdb3 --- /dev/null +++ b/packages/twenty-server/src/modules/match-participant/utils/__tests__/find-person-by-primary-or-additional-email.spec.ts @@ -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]); + }); +}); diff --git a/packages/twenty-server/src/modules/match-participant/utils/add-person-email-filters-to-query-builder.ts b/packages/twenty-server/src/modules/match-participant/utils/add-person-email-filters-to-query-builder.ts new file mode 100644 index 000000000..a3f98e721 --- /dev/null +++ b/packages/twenty-server/src/modules/match-participant/utils/add-person-email-filters-to-query-builder.ts @@ -0,0 +1,63 @@ +import { SelectQueryBuilder } from 'typeorm'; + +import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; + +export interface AddPersonEmailFiltersToQueryBuilderOptions { + queryBuilder: SelectQueryBuilder; + 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 { + 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; +} diff --git a/packages/twenty-server/src/modules/match-participant/utils/find-person-by-primary-or-additional-email.ts b/packages/twenty-server/src/modules/match-participant/utils/find-person-by-primary-or-additional-email.ts new file mode 100644 index 000000000..54ed6312f --- /dev/null +++ b/packages/twenty-server/src/modules/match-participant/utils/find-person-by-primary-or-additional-email.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-match-participant.job.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-match-participant.job.ts index a72f4671b..d19e9a9ff 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-match-participant.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-match-participant.job.ts @@ -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 { - 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, + }, + ); + } } } diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-unmatch-participant.job.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-unmatch-participant.job.ts index 8d7cc46b2..6fd9d4391 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-unmatch-participant.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/message-participant-unmatch-participant.job.ts @@ -28,11 +28,11 @@ export class MessageParticipantUnmatchParticipantJob { ): Promise { const { email, personId, workspaceMemberId } = data; - await this.matchParticipantService.unmatchParticipants( - email, - 'messageParticipant', + await this.matchParticipantService.unmatchParticipants({ + handle: email, + objectMetadataName: 'messageParticipant', personId, workspaceMemberId, - ); + }); } } diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-person.listener.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-person.listener.ts index c1b66b299..e335e7da5 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-person.listener.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-person.listener.ts @@ -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[] = []; + + if (isDefined(eventPayload.properties.after.emails?.primaryEmail)) { + jobPromises.push( + this.messageQueueService.add( + MessageParticipantMatchParticipantJob.name, + { + workspaceId: payload.workspaceId, + email: eventPayload.properties.after.emails?.primaryEmail, + isPrimaryEmail: true, + personId: eventPayload.recordId, + }, + ), + ); } - await this.messageQueueService.add( - 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( + 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( - MessageParticipantUnmatchParticipantJob.name, - { - workspaceId: payload.workspaceId, - email: eventPayload.properties.before.emails?.primaryEmail, - personId: eventPayload.recordId, - }, + if (!isDefined(eventPayload.properties.diff)) { + continue; + } + + const jobPromises: Promise[] = []; + + if (hasPrimaryEmailChanged(eventPayload.properties.diff)) { + if (eventPayload.properties.before.emails?.primaryEmail) { + jobPromises.push( + this.messageQueueService.add( + 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( + 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( + MessageParticipantUnmatchParticipantJob.name, + { + workspaceId: payload.workspaceId, + email: email, + personId: eventPayload.recordId, + }, + ), ); - await this.messageQueueService.add( - MessageParticipantMatchParticipantJob.name, - { - workspaceId: payload.workspaceId, - email: eventPayload.properties.after.emails?.primaryEmail, - personId: eventPayload.recordId, - }, + const addedEmailPromises = addedAdditionalEmails.map((email) => + this.messageQueueService.add( + 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 + >, + ) { + for (const eventPayload of payload.events) { + await this.messageQueueService.add( + 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( + MessageParticipantUnmatchParticipantJob.name, + { + workspaceId: payload.workspaceId, + email: email, + personId: eventPayload.recordId, + }, + ), + ); + + await Promise.all(additionalEmailPromises); } } } diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-workspace-member.listener.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-workspace-member.listener.ts index 5cb969a3d..53e706f8c 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-workspace-member.listener.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/listeners/message-participant-workspace-member.listener.ts @@ -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, }, ); } diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/services/messaging-message-participant.service.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/services/messaging-message-participant.service.ts index c8c88c939..4f29a7b15 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/services/messaging-message-participant.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/services/messaging-message-participant.service.ts @@ -35,10 +35,10 @@ export class MessagingMessageParticipantService { transactionManager, ); - await this.matchParticipantService.matchParticipants( - savedParticipants, - 'messageParticipant', + await this.matchParticipantService.matchParticipants({ + participants: savedParticipants, + objectMetadataName: 'messageParticipant', transactionManager, - ); + }); } }