diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 86599f0edc..3a88c7286c 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -44,6 +44,8 @@ "graphql-middleware": "^6.1.35", "jwt-decode": "^4.0.0", "lodash.differencewith": "^4.5.0", + "lodash.uniq": "^4.5.0", + "lodash.uniqby": "^4.7.0", "passport": "^0.7.0", "psl": "^1.9.0", "tsconfig-paths": "^4.2.0" @@ -57,6 +59,8 @@ "@types/lodash.isobject": "^3.0.7", "@types/lodash.omit": "^4.5.9", "@types/lodash.snakecase": "^4.1.7", + "@types/lodash.uniq": "^4.5.9", + "@types/lodash.uniqby": "^4.7.9", "@types/lodash.upperfirst": "^4.3.7", "@types/react": "^18.2.39", "rimraf": "^5.0.5", diff --git a/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts index 8340ec4ad2..ce7ca5e997 100644 --- a/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/integrations/message-queue/jobs.module.ts @@ -18,7 +18,7 @@ import { EnvironmentModule } from 'src/engine/integrations/environment/environme import { FetchAllWorkspacesMessagesJob } from 'src/modules/messaging/commands/crons/fetch-all-workspaces-messages.job'; import { MatchMessageParticipantJob } from 'src/modules/messaging/jobs/match-message-participant.job'; import { CreateCompaniesAndContactsAfterSyncJob } from 'src/modules/messaging/jobs/create-companies-and-contacts-after-sync.job'; -import { CreateCompaniesAndContactsModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.module'; +import { AutoCompaniesAndContactsCreationModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module'; import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-demo-workspace/data-seed-demo-workspace.module'; import { DataSeedDemoWorkspaceJob } from 'src/database/commands/data-seed-demo-workspace/jobs/data-seed-demo-workspace.job'; import { DeleteConnectedAccountAssociatedMessagingDataJob } from 'src/modules/messaging/jobs/delete-connected-account-associated-messaging-data.job'; @@ -44,6 +44,7 @@ import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repos import { ConnectedAccountObjectMetadata } from 'src/modules/connected-account/standard-objects/connected-account.object-metadata'; import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard-objects/message-participant.object-metadata'; import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; +import { CreateCompanyAndContactJob } from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job'; import { SaveEventToDbJob } from 'src/engine/api/graphql/workspace-query-runner/jobs/save-event-to-db.job'; import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.object-metadata'; @@ -51,7 +52,7 @@ import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.ob imports: [ BillingModule, DataSourceModule, - CreateCompaniesAndContactsModule, + AutoCompaniesAndContactsCreationModule, DataSeedDemoWorkspaceModule, EnvironmentModule, HttpModule, @@ -133,6 +134,10 @@ import { EventObjectMetadata } from 'src/modules/event/standard-objects/event.ob provide: RecordPositionBackfillJob.name, useClass: RecordPositionBackfillJob, }, + { + provide: CreateCompanyAndContactJob.name, + useClass: CreateCompanyAndContactJob, + }, { provide: SaveEventToDbJob.name, useClass: SaveEventToDbJob, diff --git a/packages/twenty-server/src/engine/integrations/message-queue/message-queue.constants.ts b/packages/twenty-server/src/engine/integrations/message-queue/message-queue.constants.ts index e3a319e029..64d576839c 100644 --- a/packages/twenty-server/src/engine/integrations/message-queue/message-queue.constants.ts +++ b/packages/twenty-server/src/engine/integrations/message-queue/message-queue.constants.ts @@ -7,6 +7,7 @@ export enum MessageQueue { cronQueue = 'cron-queue', emailQueue = 'email-queue', calendarQueue = 'calendar-queue', + contactCreationQueue = 'contact-creation-queue', billingQueue = 'billing-queue', recordPositionBackfillQueue = 'record-position-backfill-queue', entityEventsToDbQueue = 'entity-events-to-db-queue', diff --git a/packages/twenty-server/src/modules/calendar/repositories/calendar-event-attendee.repository.ts b/packages/twenty-server/src/modules/calendar/repositories/calendar-event-attendee.repository.ts index 20b39a3ac0..2a05e5e64d 100644 --- a/packages/twenty-server/src/modules/calendar/repositories/calendar-event-attendee.repository.ts +++ b/packages/twenty-server/src/modules/calendar/repositories/calendar-event-attendee.repository.ts @@ -7,7 +7,10 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; import { CalendarEventAttendeeObjectMetadata } from 'src/modules/calendar/standard-objects/calendar-event-attendee.object-metadata'; import { getFlattenedValuesAndValuesStringForBatchRawQuery } from 'src/modules/calendar/utils/getFlattenedValuesAndValuesStringForBatchRawQuery.util'; -import { CalendarEventAttendee } from 'src/modules/calendar/types/calendar-event'; +import { + CalendarEventAttendee, + CalendarEventAttendeeWithId, +} from 'src/modules/calendar/types/calendar-event'; @Injectable() export class CalendarEventAttendeeRepository { @@ -172,4 +175,29 @@ export class CalendarEventAttendeeRepository { transactionManager, ); } + + public async getWithoutPersonIdAndWorkspaceMemberId( + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + if (!workspaceId) { + throw new Error('WorkspaceId is required'); + } + + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const calendarEventAttendees: CalendarEventAttendeeWithId[] = + await this.workspaceDataSourceService.executeRawQuery( + `SELECT "calendarEventAttendee".* + FROM ${dataSourceSchema}."calendarEventAttendee" AS "calendarEventAttendee" + WHERE "calendarEventAttendee"."personId" IS NULL + AND "calendarEventAttendee"."workspaceMemberId" IS NULL`, + [], + workspaceId, + transactionManager, + ); + + return calendarEventAttendees; + } } diff --git a/packages/twenty-server/src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.module.ts b/packages/twenty-server/src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.module.ts new file mode 100644 index 0000000000..27b5ccb848 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { CalendarEventAttendeeService } from 'src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.service'; +import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; + +@Module({ + imports: [ + WorkspaceDataSourceModule, + ObjectMetadataRepositoryModule.forFeature([PersonObjectMetadata]), + ], + providers: [CalendarEventAttendeeService], + exports: [CalendarEventAttendeeService], +}) +export class CalendarEventAttendeeModule {} diff --git a/packages/twenty-server/src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.service.ts b/packages/twenty-server/src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.service.ts new file mode 100644 index 0000000000..6ffbedbd71 --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { PersonRepository } from 'src/modules/person/repositories/person.repository'; +import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { getFlattenedValuesAndValuesStringForBatchRawQuery } from 'src/modules/calendar/utils/getFlattenedValuesAndValuesStringForBatchRawQuery.util'; +import { CalendarEventAttendeeWithId } from 'src/modules/calendar/types/calendar-event'; + +@Injectable() +export class CalendarEventAttendeeService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + @InjectObjectMetadataRepository(PersonObjectMetadata) + private readonly personRepository: PersonRepository, + ) {} + + public async updateCalendarEventAttendeesAfterContactCreation( + attendees: CalendarEventAttendeeWithId[], + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + if (!attendees) return; + + const dataSourceSchema = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const handles = attendees.map((attendee) => attendee.handle); + + const attendeePersonIds = await this.personRepository.getByEmails( + handles, + workspaceId, + transactionManager, + ); + + const calendarEventAttendeesToUpdate = attendees.map((attendee) => ({ + id: attendee.id, + personId: attendeePersonIds.find( + (e: { id: string; email: string }) => e.email === attendee.handle, + )?.id, + })); + + if (calendarEventAttendeesToUpdate.length === 0) return; + + const { flattenedValues, valuesString } = + getFlattenedValuesAndValuesStringForBatchRawQuery( + calendarEventAttendeesToUpdate, + { + id: 'uuid', + personId: 'uuid', + }, + ); + + await this.workspaceDataSourceService.executeRawQuery( + `UPDATE ${dataSourceSchema}."calendarEventAttendee" AS "calendarEventAttendee" SET "personId" = "data"."personId" + FROM (VALUES ${valuesString}) AS "data"("id", "personId") + WHERE "calendarEventAttendee"."id" = "data"."id"`, + flattenedValues, + workspaceId, + transactionManager, + ); + } +} diff --git a/packages/twenty-server/src/modules/calendar/services/google-calendar-full-sync.service.ts b/packages/twenty-server/src/modules/calendar/services/google-calendar-full-sync.service.ts index ab418cdafc..f0ad984ce6 100644 --- a/packages/twenty-server/src/modules/calendar/services/google-calendar-full-sync.service.ts +++ b/packages/twenty-server/src/modules/calendar/services/google-calendar-full-sync.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Repository } from 'typeorm'; @@ -53,6 +54,7 @@ export class GoogleCalendarFullSyncService { @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly eventEmitter: EventEmitter2, ) {} public async startGoogleCalendarFullSync( @@ -294,6 +296,16 @@ export class GoogleCalendarFullSyncService { }ms.`, ); }); + + if (calendarChannel.isContactAutoCreationEnabled) { + const contactsToCreate = attendeesToSave; + + this.eventEmitter.emit(`createContacts`, { + workspaceId, + connectedAccountHandle: connectedAccount.handle, + contactsToCreate, + }); + } } catch (error) { this.logger.error( `Error during google calendar full-sync for workspace ${workspaceId} and account ${connectedAccountId}: ${error.message}`, diff --git a/packages/twenty-server/src/modules/calendar/types/calendar-event.ts b/packages/twenty-server/src/modules/calendar/types/calendar-event.ts index 83db8d0d8b..4cd9450932 100644 --- a/packages/twenty-server/src/modules/calendar/types/calendar-event.ts +++ b/packages/twenty-server/src/modules/calendar/types/calendar-event.ts @@ -29,3 +29,7 @@ export type CalendarEventWithAttendees = CalendarEvent & { externalId: string; attendees: CalendarEventAttendee[]; }; + +export type CalendarEventAttendeeWithId = CalendarEventAttendee & { + id: string; +}; diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module.ts new file mode 100644 index 0000000000..fa6206a398 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; + +import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service'; +import { CreateCompanyModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.module'; +import { CreateContactModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.module'; +import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; +import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; +import { MessageParticipantModule } from 'src/modules/messaging/services/message-participant/message-participant.module'; +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { CreateCompanyAndContactListener } from 'src/modules/connected-account/auto-companies-and-contacts-creation/listeners/create-company-and-contact.listener'; +import { CalendarEventAttendeeObjectMetadata } from 'src/modules/calendar/standard-objects/calendar-event-attendee.object-metadata'; +import { CalendarEventAttendeeModule } from 'src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.module'; + +@Module({ + imports: [ + CreateContactModule, + CreateCompanyModule, + ObjectMetadataRepositoryModule.forFeature([ + PersonObjectMetadata, + WorkspaceMemberObjectMetadata, + CalendarEventAttendeeObjectMetadata, + ]), + MessageParticipantModule, + WorkspaceDataSourceModule, + CalendarEventAttendeeModule, + ], + providers: [CreateCompanyAndContactService, CreateCompanyAndContactListener], + exports: [CreateCompanyAndContactService], +}) +export class AutoCompaniesAndContactsCreationModule {} diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.module.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.module.ts deleted file mode 100644 index 3c0f8e2624..0000000000 --- a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.service'; -import { CreateCompanyModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.module'; -import { CreateContactModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.module'; -import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; -import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; -import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; - -@Module({ - imports: [ - CreateContactModule, - CreateCompanyModule, - ObjectMetadataRepositoryModule.forFeature([ - PersonObjectMetadata, - WorkspaceMemberObjectMetadata, - ]), - ], - providers: [CreateCompanyAndContactService], - exports: [CreateCompanyAndContactService], -}) -export class CreateCompaniesAndContactsModule {} diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.service.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.service.ts deleted file mode 100644 index cf1227048f..0000000000 --- a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.service.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { EntityManager } from 'typeorm'; -import compact from 'lodash/compact'; - -import { Participant } from 'src/modules/messaging/types/gmail-message'; -import { getDomainNameFromHandle } from 'src/modules/messaging/utils/get-domain-name-from-handle.util'; -import { CreateCompanyService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.service'; -import { CreateContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.service'; -import { PersonRepository } from 'src/modules/person/repositories/person.repository'; -import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; -import { getUniqueParticipantsAndHandles } from 'src/modules/messaging/utils/get-unique-participants-and-handles.util'; -import { filterOutParticipantsFromCompanyOrWorkspace } from 'src/modules/messaging/utils/filter-out-participants-from-company-or-workspace.util'; -import { isWorkEmail } from 'src/utils/is-work-email'; -import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; -import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; -import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; - -@Injectable() -export class CreateCompanyAndContactService { - constructor( - private readonly createContactService: CreateContactService, - private readonly createCompaniesService: CreateCompanyService, - @InjectObjectMetadataRepository(PersonObjectMetadata) - private readonly personRepository: PersonRepository, - @InjectObjectMetadataRepository(WorkspaceMemberObjectMetadata) - private readonly workspaceMemberRepository: WorkspaceMemberRepository, - ) {} - - async createCompaniesAndContacts( - selfHandle: string, - participants: Participant[], - workspaceId: string, - transactionManager?: EntityManager, - ) { - if (!participants || participants.length === 0) { - return; - } - - // TODO: This is a feature that may be implemented in the future - const isContactAutoCreationForNonWorkEmailsEnabled = false; - - const workspaceMembers = - await this.workspaceMemberRepository.getAllByWorkspaceId( - workspaceId, - transactionManager, - ); - - const participantsFromOtherCompanies = - filterOutParticipantsFromCompanyOrWorkspace( - participants, - selfHandle, - workspaceMembers, - ); - - const { uniqueParticipants, uniqueHandles } = - getUniqueParticipantsAndHandles(participantsFromOtherCompanies); - - if (uniqueHandles.length === 0) { - return; - } - - const alreadyCreatedContacts = await this.personRepository.getByEmails( - uniqueHandles, - workspaceId, - ); - - const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map( - ({ email }) => email, - ); - - const filteredParticipants = uniqueParticipants.filter( - (participant) => - !alreadyCreatedContactEmails.includes(participant.handle) && - participant.handle.includes('@') && - (isContactAutoCreationForNonWorkEmailsEnabled || - isWorkEmail(participant.handle)), - ); - - const filteredParticipantsWithCompanyDomainNames = - filteredParticipants?.map((participant) => ({ - handle: participant.handle, - displayName: participant.displayName, - companyDomainName: isWorkEmail(participant.handle) - ? getDomainNameFromHandle(participant.handle) - : undefined, - })); - - const domainNamesToCreate = compact( - filteredParticipantsWithCompanyDomainNames.map( - (participant) => participant.companyDomainName, - ), - ); - - const companiesObject = await this.createCompaniesService.createCompanies( - domainNamesToCreate, - workspaceId, - transactionManager, - ); - - const contactsToCreate = filteredParticipantsWithCompanyDomainNames.map( - (participant) => ({ - handle: participant.handle, - displayName: participant.displayName, - companyId: - participant.companyDomainName && - companiesObject[participant.companyDomainName], - }), - ); - - await this.createContactService.createContacts( - contactsToCreate, - workspaceId, - transactionManager, - ); - } -} diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts new file mode 100644 index 0000000000..e9717649c5 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; + +import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface'; + +import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service'; + +export type CreateCompanyAndContactJobData = { + workspaceId: string; + connectedAccountHandle: string; + contactsToCreate: { + displayName: string; + handle: string; + }[]; +}; + +@Injectable() +export class CreateCompanyAndContactJob + implements MessageQueueJob +{ + constructor( + private readonly createCompanyAndContactService: CreateCompanyAndContactService, + ) {} + + async handle(data: CreateCompanyAndContactJobData): Promise { + const { workspaceId, connectedAccountHandle, contactsToCreate } = data; + + await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( + connectedAccountHandle, + contactsToCreate.map((contact) => ({ + handle: contact.handle, + displayName: contact.displayName, + })), + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/listeners/create-company-and-contact.listener.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/listeners/create-company-and-contact.listener.ts new file mode 100644 index 0000000000..40b8741b09 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/listeners/create-company-and-contact.listener.ts @@ -0,0 +1,32 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/integrations/message-queue/services/message-queue.service'; +import { + CreateCompanyAndContactJobData, + CreateCompanyAndContactJob, +} from 'src/modules/connected-account/auto-companies-and-contacts-creation/jobs/create-company-and-contact.job'; + +@Injectable() +export class CreateCompanyAndContactListener { + constructor( + @Inject(MessageQueue.contactCreationQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + @OnEvent('createContacts') + async handleContactCreationEvent(payload: { + workspaceId: string; + connectedAccountHandle: string; + contactsToCreate: { + displayName: string; + handle: string; + }[]; + }) { + await this.messageQueueService.add( + CreateCompanyAndContactJob.name, + payload, + ); + } +} diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts new file mode 100644 index 0000000000..ca8d53fc43 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; +import compact from 'lodash/compact'; + +import { getDomainNameFromHandle } from 'src/modules/messaging/utils/get-domain-name-from-handle.util'; +import { CreateCompanyService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company/create-company.service'; +import { CreateContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-contact/create-contact.service'; +import { PersonRepository } from 'src/modules/person/repositories/person.repository'; +import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository'; +import { isWorkEmail } from 'src/utils/is-work-email'; +import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; +import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; +import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; +import { getUniqueContactsAndHandles } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util'; +import { Contacts } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type'; +import { MessageParticipantRepository } from 'src/modules/messaging/repositories/message-participant.repository'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { MessageParticipantService } from 'src/modules/messaging/services/message-participant/message-participant.service'; +import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard-objects/message-participant.object-metadata'; +import { CalendarEventAttendeeService } from 'src/modules/calendar/services/calendar-event-attendee/calendar-event-attendee.service'; +import { CalendarEventAttendeeRepository } from 'src/modules/calendar/repositories/calendar-event-attendee.repository'; +import { CalendarEventAttendeeObjectMetadata } from 'src/modules/calendar/standard-objects/calendar-event-attendee.object-metadata'; +import { filterOutContactsFromCompanyOrWorkspace } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util'; + +@Injectable() +export class CreateCompanyAndContactService { + constructor( + private readonly createContactService: CreateContactService, + private readonly createCompaniesService: CreateCompanyService, + @InjectObjectMetadataRepository(PersonObjectMetadata) + private readonly personRepository: PersonRepository, + @InjectObjectMetadataRepository(WorkspaceMemberObjectMetadata) + private readonly workspaceMemberRepository: WorkspaceMemberRepository, + @InjectObjectMetadataRepository(MessageParticipantObjectMetadata) + private readonly messageParticipantRepository: MessageParticipantRepository, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly messageParticipantService: MessageParticipantService, + @InjectObjectMetadataRepository(CalendarEventAttendeeObjectMetadata) + private readonly calendarEventAttendeeRepository: CalendarEventAttendeeRepository, + private readonly calendarEventAttendeeService: CalendarEventAttendeeService, + ) {} + + async createCompaniesAndContacts( + connectedAccountHandle: string, + contactsToCreate: Contacts, + workspaceId: string, + transactionManager?: EntityManager, + ) { + if (!contactsToCreate || contactsToCreate.length === 0) { + return; + } + + // TODO: This is a feature that may be implemented in the future + const isContactAutoCreationForNonWorkEmailsEnabled = false; + + const workspaceMembers = + await this.workspaceMemberRepository.getAllByWorkspaceId( + workspaceId, + transactionManager, + ); + + const contactsToCreateFromOtherCompanies = contactsToCreate; + + filterOutContactsFromCompanyOrWorkspace( + contactsToCreate, + connectedAccountHandle, + workspaceMembers, + ); + + const { uniqueContacts, uniqueHandles } = getUniqueContactsAndHandles( + contactsToCreateFromOtherCompanies, + ); + + if (uniqueHandles.length === 0) { + return; + } + + const alreadyCreatedContacts = await this.personRepository.getByEmails( + uniqueHandles, + workspaceId, + transactionManager, + ); + + const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map( + ({ email }) => email, + ); + + const filteredContactsToCreate = uniqueContacts.filter( + (participant) => + !alreadyCreatedContactEmails.includes(participant.handle) && + participant.handle.includes('@') && + (isContactAutoCreationForNonWorkEmailsEnabled || + isWorkEmail(participant.handle)), + ); + + const filteredContactsToCreateWithCompanyDomainNames = + filteredContactsToCreate?.map((participant) => ({ + handle: participant.handle, + displayName: participant.displayName, + companyDomainName: isWorkEmail(participant.handle) + ? getDomainNameFromHandle(participant.handle) + : undefined, + })); + + const domainNamesToCreate = compact( + filteredContactsToCreateWithCompanyDomainNames.map( + (participant) => participant.companyDomainName, + ), + ); + + const companiesObject = await this.createCompaniesService.createCompanies( + domainNamesToCreate, + workspaceId, + transactionManager, + ); + + const formattedContactsToCreate = + filteredContactsToCreateWithCompanyDomainNames.map((contact) => ({ + handle: contact.handle, + displayName: contact.displayName, + companyId: + contact.companyDomainName && + companiesObject[contact.companyDomainName], + })); + + await this.createContactService.createContacts( + formattedContactsToCreate, + workspaceId, + transactionManager, + ); + } + + async createCompaniesAndContactsAndUpdateParticipants( + connectedAccountHandle: string, + contactsToCreate: Contacts, + workspaceId: string, + ) { + const { dataSource: workspaceDataSource } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + await workspaceDataSource?.transaction( + async (transactionManager: EntityManager) => { + await this.createCompaniesAndContacts( + connectedAccountHandle, + contactsToCreate, + workspaceId, + transactionManager, + ); + + const messageParticipantsWithoutPersonIdAndWorkspaceMemberId = + await this.messageParticipantRepository.getWithoutPersonIdAndWorkspaceMemberId( + workspaceId, + transactionManager, + ); + + await this.messageParticipantService.updateMessageParticipantsAfterPeopleCreation( + messageParticipantsWithoutPersonIdAndWorkspaceMemberId, + workspaceId, + transactionManager, + ); + + const calendarEventAttendeesWithoutPersonIdAndWorkspaceMemberId = + await this.calendarEventAttendeeRepository.getWithoutPersonIdAndWorkspaceMemberId( + workspaceId, + transactionManager, + ); + + await this.calendarEventAttendeeService.updateCalendarEventAttendeesAfterContactCreation( + calendarEventAttendeesWithoutPersonIdAndWorkspaceMemberId, + workspaceId, + transactionManager, + ); + }, + ); + } +} diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type.ts new file mode 100644 index 0000000000..0cd41a5b76 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type.ts @@ -0,0 +1,6 @@ +export type Contact = { + handle: string; + displayName: string; +}; + +export type Contacts = Contact[]; diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/__tests__/get-unique-contacts-and-handles.spec.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/__tests__/get-unique-contacts-and-handles.spec.ts new file mode 100644 index 0000000000..87537a8e88 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/__tests__/get-unique-contacts-and-handles.spec.ts @@ -0,0 +1,32 @@ +import { Contacts } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type'; +import { getUniqueContactsAndHandles } from 'src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util'; + +describe('getUniqueContactsAndHandles', () => { + it('should return empty arrays when contacts is empty', () => { + const contacts: Contacts = []; + const result = getUniqueContactsAndHandles(contacts); + + expect(result.uniqueContacts).toEqual([]); + expect(result.uniqueHandles).toEqual([]); + }); + + it('should return unique contacts and handles', () => { + const contacts: Contacts = [ + { handle: 'john@twenty.com', displayName: 'John Doe' }, + { handle: 'john@twenty.com', displayName: 'John Doe' }, + { handle: 'jane@twenty.com', displayName: 'Jane Smith' }, + { handle: 'jane@twenty.com', displayName: 'Jane Smith' }, + { handle: 'jane@twenty.com', displayName: 'Jane Smith' }, + ]; + const result = getUniqueContactsAndHandles(contacts); + + expect(result.uniqueContacts).toEqual([ + { handle: 'john@twenty.com', displayName: 'John Doe' }, + { handle: 'jane@twenty.com', displayName: 'Jane Smith' }, + ]); + expect(result.uniqueHandles).toEqual([ + 'john@twenty.com', + 'jane@twenty.com', + ]); + }); +}); diff --git a/packages/twenty-server/src/modules/messaging/utils/filter-out-participants-from-company-or-workspace.util.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts similarity index 65% rename from packages/twenty-server/src/modules/messaging/utils/filter-out-participants-from-company-or-workspace.util.ts rename to packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts index ec569804ef..37e52e333d 100644 --- a/packages/twenty-server/src/modules/messaging/utils/filter-out-participants-from-company-or-workspace.util.ts +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/filter-out-contacts-from-company-or-workspace.util.ts @@ -1,13 +1,13 @@ -import { Participant } from 'src/modules/messaging/types/gmail-message'; import { getDomainNameFromHandle } from 'src/modules/messaging/utils/get-domain-name-from-handle.util'; import { WorkspaceMemberObjectMetadata } from 'src/modules/workspace-member/standard-objects/workspace-member.object-metadata'; import { ObjectRecord } from 'src/engine/workspace-manager/workspace-sync-metadata/types/object-record'; +import { Contacts } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type'; -export function filterOutParticipantsFromCompanyOrWorkspace( - participants: Participant[], +export function filterOutContactsFromCompanyOrWorkspace( + contacts: Contacts, selfHandle: string, workspaceMembers: ObjectRecord[], -): Participant[] { +): Contacts { const selfDomainName = getDomainNameFromHandle(selfHandle); const workspaceMembersMap = workspaceMembers.reduce( @@ -19,9 +19,9 @@ export function filterOutParticipantsFromCompanyOrWorkspace( new Map(), ); - return participants.filter( - (participant) => - getDomainNameFromHandle(participant.handle) !== selfDomainName && - !workspaceMembersMap[participant.handle], + return contacts.filter( + (contact) => + getDomainNameFromHandle(contact.handle) !== selfDomainName && + !workspaceMembersMap[contact.handle], ); } diff --git a/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util.ts b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util.ts new file mode 100644 index 0000000000..a6296e3d98 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/auto-companies-and-contacts-creation/utils/get-unique-contacts-and-handles.util.ts @@ -0,0 +1,19 @@ +import uniq from 'lodash.uniq'; +import uniqBy from 'lodash.uniqby'; + +import { Contacts } from 'src/modules/connected-account/auto-companies-and-contacts-creation/types/contact.type'; + +export function getUniqueContactsAndHandles(contacts: Contacts): { + uniqueContacts: Contacts; + uniqueHandles: string[]; +} { + if (contacts.length === 0) { + return { uniqueContacts: [], uniqueHandles: [] }; + } + + const uniqueHandles = uniq(contacts.map((participant) => participant.handle)); + + const uniqueContacts = uniqBy(contacts, 'handle'); + + return { uniqueContacts, uniqueHandles }; +} diff --git a/packages/twenty-server/src/modules/messaging/jobs/create-companies-and-contacts-after-sync.job.ts b/packages/twenty-server/src/modules/messaging/jobs/create-companies-and-contacts-after-sync.job.ts index 4908da0a32..8e8e8918c2 100644 --- a/packages/twenty-server/src/modules/messaging/jobs/create-companies-and-contacts-after-sync.job.ts +++ b/packages/twenty-server/src/modules/messaging/jobs/create-companies-and-contacts-after-sync.job.ts @@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { MessageQueueJob } from 'src/engine/integrations/message-queue/interfaces/message-queue-job.interface'; import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator'; -import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.service'; +import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/services/create-company-and-contact.service'; import { MessageChannelRepository } from 'src/modules/messaging/repositories/message-channel.repository'; import { MessageParticipantRepository } from 'src/modules/messaging/repositories/message-participant.repository'; import { MessageParticipantService } from 'src/modules/messaging/services/message-participant/message-participant.service'; diff --git a/packages/twenty-server/src/modules/messaging/repositories/message-participant.repository.ts b/packages/twenty-server/src/modules/messaging/repositories/message-participant.repository.ts index 839cf63ca2..ae176db29e 100644 --- a/packages/twenty-server/src/modules/messaging/repositories/message-participant.repository.ts +++ b/packages/twenty-server/src/modules/messaging/repositories/message-participant.repository.ts @@ -102,8 +102,7 @@ export class MessageParticipantRepository { return messageParticipants; } - public async getByHandlesWithoutPersonIdAndWorkspaceMemberId( - handles: string[], + public async getWithoutPersonIdAndWorkspaceMemberId( workspaceId: string, transactionManager?: EntityManager, ): Promise { @@ -116,18 +115,11 @@ export class MessageParticipantRepository { const messageParticipants: ParticipantWithId[] = await this.workspaceDataSourceService.executeRawQuery( - `SELECT "messageParticipant".id, - "messageParticipant"."role", - "messageParticipant"."handle", - "messageParticipant"."displayName", - "messageParticipant"."personId", - "messageParticipant"."workspaceMemberId", - "messageParticipant"."messageId" + `SELECT "messageParticipant".* FROM ${dataSourceSchema}."messageParticipant" "messageParticipant" WHERE "messageParticipant"."personId" IS NULL - AND "messageParticipant"."workspaceMemberId" IS NULL - AND "messageParticipant"."handle" = ANY($1)`, - [handles], + AND "messageParticipant"."workspaceMemberId" IS NULL`, + [], workspaceId, transactionManager, ); diff --git a/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.module.ts b/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.module.ts index eeb376b060..357d3b5a45 100644 --- a/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.module.ts +++ b/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.module.ts @@ -8,7 +8,7 @@ import { ConnectedAccountObjectMetadata } from 'src/modules/connected-account/st import { FetchMessagesByBatchesModule } from 'src/modules/messaging/services/fetch-messages-by-batches/fetch-messages-by-batches.module'; import { GmailFullSyncService } from 'src/modules/messaging/services/gmail-full-sync/gmail-full-sync.service'; import { MessagingProvidersModule } from 'src/modules/messaging/services/providers/messaging-providers.module'; -import { SaveMessagesAndCreateContactsModule } from 'src/modules/messaging/services/save-message-and-create-contact/save-message-and-create-contacts.module'; +import { SaveMessageAndEmitContactCreationEventModule } from 'src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.module'; import { MessageChannelMessageAssociationObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel-message-association.object-metadata'; import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; @@ -22,7 +22,7 @@ import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-obj MessageChannelMessageAssociationObjectMetadata, BlocklistObjectMetadata, ]), - SaveMessagesAndCreateContactsModule, + SaveMessageAndEmitContactCreationEventModule, TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), ], providers: [GmailFullSyncService], diff --git a/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.service.ts b/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.service.ts index 65ac57ee87..d3ae3ab56e 100644 --- a/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.service.ts +++ b/packages/twenty-server/src/modules/messaging/services/gmail-full-sync/gmail-full-sync.service.ts @@ -17,7 +17,7 @@ import { MessageChannelMessageAssociationRepository } from 'src/modules/messagin import { createQueriesFromMessageIds } from 'src/modules/messaging/utils/create-queries-from-message-ids.util'; import { gmailSearchFilterExcludeEmails } from 'src/modules/messaging/utils/gmail-search-filter.util'; import { BlocklistRepository } from 'src/modules/connected-account/repositories/blocklist.repository'; -import { SaveMessagesAndCreateContactsService } from 'src/modules/messaging/services/save-message-and-create-contact/save-messages-and-create-contacts.service'; +import { SaveMessageAndEmitContactCreationEventService } from 'src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.service'; import { FeatureFlagEntity, FeatureFlagKeys, @@ -47,7 +47,7 @@ export class GmailFullSyncService { private readonly messageChannelMessageAssociationRepository: MessageChannelMessageAssociationRepository, @InjectObjectMetadataRepository(BlocklistObjectMetadata) private readonly blocklistRepository: BlocklistRepository, - private readonly saveMessagesAndCreateContactsService: SaveMessagesAndCreateContactsService, + private readonly saveMessagesAndCreateContactsService: SaveMessageAndEmitContactCreationEventService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, ) {} diff --git a/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.module.ts b/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.module.ts index 09358d2360..52242798fe 100644 --- a/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.module.ts +++ b/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.module.ts @@ -9,7 +9,7 @@ import { FetchMessagesByBatchesModule } from 'src/modules/messaging/services/fet import { GmailPartialSyncService } from 'src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.service'; import { MessageModule } from 'src/modules/messaging/services/message/message.module'; import { MessagingProvidersModule } from 'src/modules/messaging/services/providers/messaging-providers.module'; -import { SaveMessagesAndCreateContactsModule } from 'src/modules/messaging/services/save-message-and-create-contact/save-message-and-create-contacts.module'; +import { SaveMessageAndEmitContactCreationEventModule } from 'src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.module'; import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; @Module({ @@ -22,7 +22,7 @@ import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-obj BlocklistObjectMetadata, ]), MessageModule, - SaveMessagesAndCreateContactsModule, + SaveMessageAndEmitContactCreationEventModule, TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), ], providers: [GmailPartialSyncService], diff --git a/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.service.ts b/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.service.ts index 197f383b2a..3bfb4df349 100644 --- a/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.service.ts +++ b/packages/twenty-server/src/modules/messaging/services/gmail-partial-sync/gmail-partial-sync.service.ts @@ -18,7 +18,7 @@ import { createQueriesFromMessageIds } from 'src/modules/messaging/utils/create- import { GmailMessage } from 'src/modules/messaging/types/gmail-message'; import { isPersonEmail } from 'src/modules/messaging/utils/is-person-email.util'; import { BlocklistRepository } from 'src/modules/connected-account/repositories/blocklist.repository'; -import { SaveMessagesAndCreateContactsService } from 'src/modules/messaging/services/save-message-and-create-contact/save-messages-and-create-contacts.service'; +import { SaveMessageAndEmitContactCreationEventService } from 'src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.service'; import { FeatureFlagEntity, FeatureFlagKeys, @@ -45,7 +45,7 @@ export class GmailPartialSyncService { private readonly messageService: MessageService, @InjectObjectMetadataRepository(BlocklistObjectMetadata) private readonly blocklistRepository: BlocklistRepository, - private readonly saveMessagesAndCreateContactsService: SaveMessagesAndCreateContactsService, + private readonly saveMessagesAndCreateContactsService: SaveMessageAndEmitContactCreationEventService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, ) {} diff --git a/packages/twenty-server/src/modules/messaging/services/message-participant/message-participant.service.ts b/packages/twenty-server/src/modules/messaging/services/message-participant/message-participant.service.ts index 7219051817..980fad697b 100644 --- a/packages/twenty-server/src/modules/messaging/services/message-participant/message-participant.service.ts +++ b/packages/twenty-server/src/modules/messaging/services/message-participant/message-participant.service.ts @@ -7,6 +7,7 @@ import { ParticipantWithId } from 'src/modules/messaging/types/gmail-message'; import { PersonRepository } from 'src/modules/person/repositories/person.repository'; import { PersonObjectMetadata } from 'src/modules/person/standard-objects/person.object-metadata'; import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { getFlattenedValuesAndValuesStringForBatchRawQuery } from 'src/modules/calendar/utils/getFlattenedValuesAndValuesStringForBatchRawQuery.util'; @Injectable() export class MessageParticipantService { @@ -34,24 +35,29 @@ export class MessageParticipantService { transactionManager, ); - const messageParticipantsToUpdate = participants.map((participant) => [ - participant.id, - participantPersonIds.find( + const messageParticipantsToUpdate = participants.map((participant) => ({ + id: participant.id, + personId: participantPersonIds.find( (e: { id: string; email: string }) => e.email === participant.handle, )?.id, - ]); + })); if (messageParticipantsToUpdate.length === 0) return; - const valuesString = messageParticipantsToUpdate - .map((_, index) => `($${index * 2 + 1}::uuid, $${index * 2 + 2}::uuid)`) - .join(', '); + const { flattenedValues, valuesString } = + getFlattenedValuesAndValuesStringForBatchRawQuery( + messageParticipantsToUpdate, + { + id: 'uuid', + personId: 'uuid', + }, + ); await this.workspaceDataSourceService.executeRawQuery( `UPDATE ${dataSourceSchema}."messageParticipant" AS "messageParticipant" SET "personId" = "data"."personId" FROM (VALUES ${valuesString}) AS "data"("id", "personId") WHERE "messageParticipant"."id" = "data"."id"`, - messageParticipantsToUpdate.flat(), + flattenedValues, workspaceId, transactionManager, ); diff --git a/packages/twenty-server/src/modules/messaging/services/save-message-and-create-contact/save-message-and-create-contacts.module.ts b/packages/twenty-server/src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.module.ts similarity index 62% rename from packages/twenty-server/src/modules/messaging/services/save-message-and-create-contact/save-message-and-create-contacts.module.ts rename to packages/twenty-server/src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.module.ts index 6f727a3f4f..647009a4c3 100644 --- a/packages/twenty-server/src/modules/messaging/services/save-message-and-create-contact/save-message-and-create-contacts.module.ts +++ b/packages/twenty-server/src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.module.ts @@ -2,10 +2,10 @@ import { Module } from '@nestjs/common'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; -import { CreateCompaniesAndContactsModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.module'; +import { AutoCompaniesAndContactsCreationModule } from 'src/modules/connected-account/auto-companies-and-contacts-creation/auto-companies-and-contacts-creation.module'; import { MessageParticipantModule } from 'src/modules/messaging/services/message-participant/message-participant.module'; import { MessageModule } from 'src/modules/messaging/services/message/message.module'; -import { SaveMessagesAndCreateContactsService } from 'src/modules/messaging/services/save-message-and-create-contact/save-messages-and-create-contacts.service'; +import { SaveMessageAndEmitContactCreationEventService } from 'src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.service'; import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard-objects/message-participant.object-metadata'; @@ -16,11 +16,11 @@ import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard MessageChannelObjectMetadata, MessageParticipantObjectMetadata, ]), - CreateCompaniesAndContactsModule, + AutoCompaniesAndContactsCreationModule, MessageParticipantModule, WorkspaceDataSourceModule, ], - providers: [SaveMessagesAndCreateContactsService], - exports: [SaveMessagesAndCreateContactsService], + providers: [SaveMessageAndEmitContactCreationEventService], + exports: [SaveMessageAndEmitContactCreationEventService], }) -export class SaveMessagesAndCreateContactsModule {} +export class SaveMessageAndEmitContactCreationEventModule {} diff --git a/packages/twenty-server/src/modules/messaging/services/save-message-and-create-contact/save-messages-and-create-contacts.service.ts b/packages/twenty-server/src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.service.ts similarity index 62% rename from packages/twenty-server/src/modules/messaging/services/save-message-and-create-contact/save-messages-and-create-contacts.service.ts rename to packages/twenty-server/src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.service.ts index cd18e83aae..6e998f7b44 100644 --- a/packages/twenty-server/src/modules/messaging/services/save-message-and-create-contact/save-messages-and-create-contacts.service.ts +++ b/packages/twenty-server/src/modules/messaging/services/save-message-and-emit-contact-creation-event/save-message-and-emit-contact-creation-event.service.ts @@ -1,10 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; - -import { EntityManager } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { MessageChannelRepository } from 'src/modules/messaging/repositories/message-channel.repository'; import { MessageParticipantRepository } from 'src/modules/messaging/repositories/message-participant.repository'; -import { CreateCompanyAndContactService } from 'src/modules/connected-account/auto-companies-and-contacts-creation/create-company-and-contact/create-company-and-contact.service'; import { GmailMessage, ParticipantWithMessageId, @@ -16,12 +14,11 @@ import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repos import { MessageChannelObjectMetadata } from 'src/modules/messaging/standard-objects/message-channel.object-metadata'; import { MessageService } from 'src/modules/messaging/services/message/message.service'; import { MessageParticipantObjectMetadata } from 'src/modules/messaging/standard-objects/message-participant.object-metadata'; -import { MessageParticipantService } from 'src/modules/messaging/services/message-participant/message-participant.service'; @Injectable() -export class SaveMessagesAndCreateContactsService { +export class SaveMessageAndEmitContactCreationEventService { private readonly logger = new Logger( - SaveMessagesAndCreateContactsService.name, + SaveMessageAndEmitContactCreationEventService.name, ); constructor( @@ -30,8 +27,7 @@ export class SaveMessagesAndCreateContactsService { private readonly messageChannelRepository: MessageChannelRepository, @InjectObjectMetadataRepository(MessageParticipantObjectMetadata) private readonly messageParticipantRepository: MessageParticipantRepository, - private readonly createCompaniesAndContactsService: CreateCompanyAndContactService, - private readonly messageParticipantService: MessageParticipantService, + private readonly eventEmitter: EventEmitter2, private readonly workspaceDataSourceService: WorkspaceDataSourceService, ) {} @@ -80,68 +76,28 @@ export class SaveMessagesAndCreateContactsService { return; } - const isContactAutoCreationEnabled = - gmailMessageChannel.isContactAutoCreationEnabled; + const participantsWithMessageId: (ParticipantWithMessageId & { + shouldCreateContact: boolean; + })[] = messagesToSave.flatMap((message) => { + const messageId = messageExternalIdsAndIdsMap.get(message.externalId); - const participantsWithMessageId: ParticipantWithMessageId[] = - messagesToSave.flatMap((message) => { - const messageId = messageExternalIdsAndIdsMap.get(message.externalId); - - return messageId - ? message.participants.map((participant) => ({ - ...participant, - messageId, - })) - : []; - }); - - const contactsToCreate = messagesToSave - .filter((message) => connectedAccount.handle === message.fromHandle) - .flatMap((message) => message.participants); - - if (isContactAutoCreationEnabled) { - startTime = Date.now(); - - await workspaceDataSource?.transaction( - async (transactionManager: EntityManager) => { - await this.createCompaniesAndContactsService.createCompaniesAndContacts( - connectedAccount.handle, - contactsToCreate, - workspaceId, - transactionManager, - ); - }, - ); - - const handles = participantsWithMessageId.map( - (participant) => participant.handle, - ); - - const messageParticipantsWithoutPersonIdAndWorkspaceMemberId = - await this.messageParticipantRepository.getByHandlesWithoutPersonIdAndWorkspaceMemberId( - handles, - workspaceId, - ); - - await this.messageParticipantService.updateMessageParticipantsAfterPeopleCreation( - messageParticipantsWithoutPersonIdAndWorkspaceMemberId, - workspaceId, - ); - - endTime = Date.now(); - - this.logger.log( - `${jobName} creating companies and contacts for workspace ${workspaceId} and account ${ - connectedAccount.id - } in ${endTime - startTime}ms`, - ); - } + return messageId + ? message.participants.map((participant) => ({ + ...participant, + messageId, + shouldCreateContact: + gmailMessageChannel.isContactAutoCreationEnabled && + message.participants.find((p) => p.role === 'from')?.handle === + connectedAccount.handle, + })) + : []; + }); startTime = Date.now(); await this.tryToSaveMessageParticipantsOrDeleteMessagesIfError( participantsWithMessageId, - gmailMessageChannelId, + gmailMessageChannel, workspaceId, connectedAccount, jobName, @@ -157,8 +113,10 @@ export class SaveMessagesAndCreateContactsService { } private async tryToSaveMessageParticipantsOrDeleteMessagesIfError( - participantsWithMessageId: ParticipantWithMessageId[], - gmailMessageChannelId: string, + participantsWithMessageId: (ParticipantWithMessageId & { + shouldCreateContact: boolean; + })[], + gmailMessageChannel: ObjectRecord, workspaceId: string, connectedAccount: ObjectRecord, jobName?: string, @@ -168,6 +126,18 @@ export class SaveMessagesAndCreateContactsService { participantsWithMessageId, workspaceId, ); + + if (gmailMessageChannel.isContactAutoCreationEnabled) { + const contactsToCreate = participantsWithMessageId.filter( + (participant) => participant.shouldCreateContact, + ); + + this.eventEmitter.emit(`createContacts`, { + workspaceId, + connectedAccountHandle: connectedAccount.handle, + contactsToCreate, + }); + } } catch (error) { this.logger.error( `${jobName} error saving message participants for workspace ${workspaceId} and account ${connectedAccount.id}`, @@ -180,7 +150,7 @@ export class SaveMessagesAndCreateContactsService { await this.messageService.deleteMessages( messagesToDelete, - gmailMessageChannelId, + gmailMessageChannel.id, workspaceId, ); } diff --git a/packages/twenty-server/src/modules/messaging/utils/get-unique-participants-and-handles.util.ts b/packages/twenty-server/src/modules/messaging/utils/get-unique-participants-and-handles.util.ts deleted file mode 100644 index 78e7587ed4..0000000000 --- a/packages/twenty-server/src/modules/messaging/utils/get-unique-participants-and-handles.util.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { uniq, uniqBy } from 'lodash'; - -import { Participant } from 'src/modules/messaging/types/gmail-message'; -export function getUniqueParticipantsAndHandles(participants: Participant[]): { - uniqueParticipants: Participant[]; - uniqueHandles: string[]; -} { - if (participants.length === 0) { - return { uniqueParticipants: [], uniqueHandles: [] }; - } - - const uniqueHandles = uniq( - participants.map((participant) => participant.handle), - ); - - const uniqueParticipants = uniqBy(participants, 'handle'); - - return { uniqueParticipants, uniqueHandles }; -} diff --git a/yarn.lock b/yarn.lock index 4664a98c94..43793e8b7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16644,6 +16644,24 @@ __metadata: languageName: node linkType: hard +"@types/lodash.uniq@npm:^4.5.9": + version: 4.5.9 + resolution: "@types/lodash.uniq@npm:4.5.9" + dependencies: + "@types/lodash": "npm:*" + checksum: feb01dfd7b6e3d2b4d29bdb0d00cce8ffa685f1c219b34ae531f098811faab11696b7bdfc3654edcf65f6180ad0cda6471cec7da8a9e7688f294b5c8177cfea7 + languageName: node + linkType: hard + +"@types/lodash.uniqby@npm:^4.7.9": + version: 4.7.9 + resolution: "@types/lodash.uniqby@npm:4.7.9" + dependencies: + "@types/lodash": "npm:*" + checksum: b508927c8bd9a840c629169f8573d0f33a12c7232b4315785537f25914dec441323c54ba609f75fdefaa53f7ecd56f29bd6f598c3920ade45532b8eab76bf044 + languageName: node + linkType: hard + "@types/lodash.upperfirst@npm:^4.3.7": version: 4.3.9 resolution: "@types/lodash.upperfirst@npm:4.3.9" @@ -33589,6 +33607,13 @@ __metadata: languageName: node linkType: hard +"lodash.uniqby@npm:^4.7.0": + version: 4.7.0 + resolution: "lodash.uniqby@npm:4.7.0" + checksum: c505c0de20ca759599a2ba38710e8fb95ff2d2028e24d86c901ef2c74be8056518571b9b754bfb75053b2818d30dd02243e4a4621a6940c206bbb3f7626db656 + languageName: node + linkType: hard + "lodash.upperfirst@npm:^4.3.1": version: 4.3.1 resolution: "lodash.upperfirst@npm:4.3.1" @@ -45770,6 +45795,8 @@ __metadata: "@types/lodash.isobject": "npm:^3.0.7" "@types/lodash.omit": "npm:^4.5.9" "@types/lodash.snakecase": "npm:^4.1.7" + "@types/lodash.uniq": "npm:^4.5.9" + "@types/lodash.uniqby": "npm:^4.7.9" "@types/lodash.upperfirst": "npm:^4.3.7" "@types/react": "npm:^18.2.39" cache-manager: "npm:^5.4.0" @@ -45778,6 +45805,8 @@ __metadata: graphql-middleware: "npm:^6.1.35" jwt-decode: "npm:^4.0.0" lodash.differencewith: "npm:^4.5.0" + lodash.uniq: "npm:^4.5.0" + lodash.uniqby: "npm:^4.7.0" passport: "npm:^0.7.0" psl: "npm:^1.9.0" rimraf: "npm:^5.0.5"