diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-deduplicate-unique-fields.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-deduplicate-unique-fields.command.ts new file mode 100644 index 0000000000000..ef6f941a3bb38 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-deduplicate-unique-fields.command.ts @@ -0,0 +1,360 @@ +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { IsNull, Not, Repository } from 'typeorm'; + +import { + ActiveOrSuspendedWorkspacesMigrationCommandRunner, + type RunOnWorkspaceArgs, +} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; +import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; +import { + WorkspaceMigrationIndexAction, + WorkspaceMigrationIndexActionType, + WorkspaceMigrationTableAction, + WorkspaceMigrationTableActionType, +} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; +import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; +import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; + +@Command({ + name: 'upgrade:1-10:deduplicate-unique-fields', + description: + 'Deduplicate unique fields for workspaceMembers, companies and people because we changed the unique constraint', +}) +export class DeduplicateUniqueFieldsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger(DeduplicateUniqueFieldsCommand.name); + constructor( + @InjectRepository(Workspace) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: TwentyORMGlobalManager, + protected readonly indexMetadataService: IndexMetadataService, + protected readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + protected readonly workspaceMigrationService: WorkspaceMigrationService, + @InjectRepository(ObjectMetadataEntity) + protected readonly objectMetadataRepository: Repository, + @InjectRepository(IndexMetadataEntity) + protected readonly indexMetadataRepository: Repository, + ) { + super(workspaceRepository, twentyORMGlobalManager); + } + + override async runOnWorkspace({ + workspaceId, + dataSource, + options, + }: RunOnWorkspaceArgs): Promise { + this.logger.log( + `Deduplicating indexed fields for workspace ${workspaceId}`, + ); + + await this.deduplicateUniqueUserEmailFieldForWorkspaceMembers({ + dataSource, + dryRun: options.dryRun ?? false, + }); + + await this.deduplicateUniqueDomainNameFieldForCompanies({ + dataSource, + dryRun: options.dryRun ?? false, + }); + + await this.deduplicateUniqueEmailFieldForPeople({ + dataSource, + dryRun: options.dryRun ?? false, + }); + + if (!options.dryRun) { + await this.updateExistingIndexedFields({ + workspaceId, + objectMetadataNameSingular: 'workspaceMember', + columnName: 'userEmail', + }); + await this.updateExistingIndexedFields({ + workspaceId, + objectMetadataNameSingular: 'company', + columnName: 'domainNamePrimaryLinkUrl', + }); + await this.updateExistingIndexedFields({ + workspaceId, + objectMetadataNameSingular: 'person', + columnName: 'emailsPrimaryEmail', + }); + } + } + + private computeExistingUniqueIndexName({ + objectMetadata, + fieldMetadataToIndex, + }: { + objectMetadata: ObjectMetadataEntity; + fieldMetadataToIndex: Partial[]; + }) { + const tableName = computeObjectTargetTable(objectMetadata); + const columnNames: string[] = fieldMetadataToIndex.map( + (fieldMetadata) => fieldMetadata.name as string, + ); + + return `IDX_UNIQUE_${generateDeterministicIndexName([tableName, ...columnNames])}`; + } + + private async computeExistingIndexDeletionMigration({ + objectMetadata, + fieldMetadataToIndex, + }: { + objectMetadata: ObjectMetadataEntity; + fieldMetadataToIndex: Partial[]; + }) { + const tableName = computeObjectTargetTable(objectMetadata); + + const indexName = this.computeExistingUniqueIndexName({ + objectMetadata, + fieldMetadataToIndex, + }); + + return { + name: tableName, + action: WorkspaceMigrationTableActionType.ALTER_INDEXES, + indexes: [ + { + action: WorkspaceMigrationIndexActionType.DROP, + name: indexName, + columns: [], + isUnique: true, + } satisfies WorkspaceMigrationIndexAction, + ], + } satisfies WorkspaceMigrationTableAction; + } + + private async updateExistingIndexedFields({ + workspaceId, + objectMetadataNameSingular, + columnName, + }: { + workspaceId: string; + objectMetadataNameSingular: string; + columnName: string; + }) { + this.logger.log( + `Updating existing indexed fields for workspace members for workspace ${workspaceId}`, + ); + + const workspaceMemberObjectMetadata = + await this.objectMetadataRepository.findOneByOrFail({ + nameSingular: objectMetadataNameSingular, + }); + + await this.indexMetadataRepository.delete({ + workspaceId, + name: this.computeExistingUniqueIndexName({ + objectMetadata: workspaceMemberObjectMetadata, + fieldMetadataToIndex: [{ name: columnName }], + }), + }); + + const indexDeletionMigration = + await this.computeExistingIndexDeletionMigration({ + objectMetadata: workspaceMemberObjectMetadata, + fieldMetadataToIndex: [{ name: columnName }], + }); + + await this.workspaceMigrationService.createCustomMigration( + generateMigrationName(`delete-${objectMetadataNameSingular}-index`), + workspaceId, + [indexDeletionMigration], + ); + + await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( + workspaceId, + ); + } + + private async deduplicateUniqueUserEmailFieldForWorkspaceMembers({ + dataSource, + dryRun, + }: { + dataSource: WorkspaceDataSource; + dryRun: boolean; + }) { + const workspaceMemberRepository = dataSource.getRepository( + 'workspaceMember', + true, + ); + + const duplicates = await workspaceMemberRepository + .createQueryBuilder('workspaceMember') + .select('workspaceMember.userEmail', 'userEmail') + .addSelect('COUNT(*)', 'count') + .andWhere('workspaceMember.userEmail IS NOT NULL') + .andWhere("workspaceMember.userEmail != ''") + .withDeleted() + .groupBy('workspaceMember.userEmail') + .having('COUNT(*) > 1') + .getRawMany(); + + for (const duplicate of duplicates) { + const { userEmail } = duplicate; + + const softDeletedWorkspaceMembers = await workspaceMemberRepository.find({ + where: { + userEmail, + deletedAt: Not(IsNull()), + }, + withDeleted: true, + }); + + for (const [ + i, + softDeletedWorkspaceMember, + ] of softDeletedWorkspaceMembers.entries()) { + const newUserEmail = this.computeNewFieldValues( + softDeletedWorkspaceMember.userEmail, + i, + ); + + if (!dryRun) { + await workspaceMemberRepository + .createQueryBuilder('workspaceMember') + .update() + .set({ + userEmail: newUserEmail, + }) + .where('id = :id', { id: softDeletedWorkspaceMember.id }) + .execute(); + } + this.logger.log( + `Updated workspaceMember ${softDeletedWorkspaceMembers[i].id} userEmail from ${userEmail} to ${newUserEmail}`, + ); + } + } + } + + private async deduplicateUniqueDomainNameFieldForCompanies({ + dataSource, + dryRun, + }: { + dataSource: WorkspaceDataSource; + dryRun: boolean; + }) { + const companyRepository = dataSource.getRepository('company', true); + + const duplicates = await companyRepository + .createQueryBuilder('company') + .select('company.domainNamePrimaryLinkUrl', 'domainNamePrimaryLinkUrl') + .addSelect('COUNT(*)', 'count') + .andWhere('company.domainNamePrimaryLinkUrl IS NOT NULL') + .andWhere("company.domainNamePrimaryLinkUrl != ''") + .withDeleted() + .groupBy('company.domainNamePrimaryLinkUrl') + .having('COUNT(*) > 1') + .getRawMany(); + + for (const duplicate of duplicates) { + const { domainNamePrimaryLinkUrl } = duplicate; + + const softDeletedCompanies = await companyRepository.find({ + where: { + domainName: { + primaryLinkUrl: domainNamePrimaryLinkUrl, + }, + deletedAt: Not(IsNull()), + }, + withDeleted: true, + }); + + for (const [i, softDeletedCompany] of softDeletedCompanies.entries()) { + const newDomainNamePrimaryLinkUrl = this.computeNewFieldValues( + softDeletedCompany.domainName.primaryLinkUrl, + i, + ); + + if (!dryRun) { + await companyRepository + .createQueryBuilder('company') + .update() + .set({ + domainName: { + primaryLinkUrl: newDomainNamePrimaryLinkUrl, + }, + }) + .where('id = :id', { id: softDeletedCompany.id }) + .execute(); + } + this.logger.log( + `Updated company ${softDeletedCompany.id} domainNamePrimaryLinkUrl from ${domainNamePrimaryLinkUrl} to ${newDomainNamePrimaryLinkUrl}`, + ); + } + } + } + + private async deduplicateUniqueEmailFieldForPeople({ + dataSource, + dryRun, + }: { + dataSource: WorkspaceDataSource; + dryRun: boolean; + }) { + const personRepository = dataSource.getRepository('person', true); + + const duplicates = await personRepository + .createQueryBuilder('person') + .select('person.emailsPrimaryEmail', 'emailsPrimaryEmail') + .addSelect('COUNT(*)', 'count') + .andWhere('person.emailsPrimaryEmail IS NOT NULL') + .andWhere("person.emailsPrimaryEmail != ''") + .withDeleted() + .groupBy('person.emailsPrimaryEmail') + .having('COUNT(*) > 1') + .getRawMany(); + + for (const duplicate of duplicates) { + const { emailsPrimaryEmail } = duplicate; + + const softDeletedPersons = await personRepository.find({ + where: { + emails: { + primaryEmail: emailsPrimaryEmail, + }, + deletedAt: Not(IsNull()), + }, + withDeleted: true, + }); + + for (const [i, softDeletedPerson] of softDeletedPersons.entries()) { + const newEmailsPrimaryEmail = this.computeNewFieldValues( + softDeletedPerson.emails.primaryEmail, + i, + ); + + if (!dryRun) { + await personRepository + .createQueryBuilder('person') + .update() + .set({ + emails: { + primaryEmail: newEmailsPrimaryEmail, + }, + }) + .where('id = :id', { id: softDeletedPerson.id }) + .execute(); + } + this.logger.log( + `Updated person ${softDeletedPerson.id} emailsPrimaryEmail from ${emailsPrimaryEmail} to ${newEmailsPrimaryEmail}`, + ); + } + } + } + + private computeNewFieldValues(fieldValue: string, i: number) { + return `${fieldValue}-old-${i}`; + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-regenerate-person-search-vector-with-phones.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-regenerate-person-search-vector-with-phones.command.ts similarity index 98% rename from packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-regenerate-person-search-vector-with-phones.command.ts rename to packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-regenerate-person-search-vector-with-phones.command.ts index 9753e72308c9a..bc5878d10e57a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-regenerate-person-search-vector-with-phones.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-regenerate-person-search-vector-with-phones.command.ts @@ -14,7 +14,7 @@ import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-mana import { SEARCH_FIELDS_FOR_PERSON } from 'src/modules/person/standard-objects/person.workspace-entity'; @Command({ - name: 'upgrade:1-7:regenerate-person-search-vector-with-phones', + name: 'upgrade:1-10:regenerate-person-search-vector-with-phones', description: 'Regenerate person search vector to include phone number indexing for existing workspaces', }) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module.ts index 7b1f5251632cf..fc6298e698a0a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-10/1-10-upgrade-version-command.module.ts @@ -1,13 +1,38 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-deduplicate-unique-fields.command'; import { MigrateWorkflowStepFilterOperandValueCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-workflow-step-filter-operand-value'; +import { RegeneratePersonSearchVectorWithPhonesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-regenerate-person-search-vector-with-phones.command'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module'; @Module({ - imports: [TypeOrmModule.forFeature([Workspace]), WorkspaceDataSourceModule], - providers: [MigrateWorkflowStepFilterOperandValueCommand], - exports: [MigrateWorkflowStepFilterOperandValueCommand], + imports: [ + TypeOrmModule.forFeature([ + Workspace, + ObjectMetadataEntity, + IndexMetadataEntity, + ]), + WorkspaceDataSourceModule, + IndexMetadataModule, + WorkspaceMigrationRunnerModule, + WorkspaceMigrationModule, + ], + providers: [ + MigrateWorkflowStepFilterOperandValueCommand, + DeduplicateUniqueFieldsCommand, + RegeneratePersonSearchVectorWithPhonesCommand, + ], + exports: [ + MigrateWorkflowStepFilterOperandValueCommand, + DeduplicateUniqueFieldsCommand, + RegeneratePersonSearchVectorWithPhonesCommand, + ], }) export class V1_10_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module.ts index b85842b572e24..7a56ecf139678 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-7/1-7-upgrade-version-command.module.ts @@ -2,19 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command'; -import { RegeneratePersonSearchVectorWithPhonesCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-regenerate-person-search-vector-with-phones.command'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; @Module({ imports: [TypeOrmModule.forFeature([Workspace]), WorkspaceDataSourceModule], - providers: [ - RegeneratePersonSearchVectorWithPhonesCommand, - BackfillWorkflowManualTriggerAvailabilityCommand, - ], - exports: [ - RegeneratePersonSearchVectorWithPhonesCommand, - BackfillWorkflowManualTriggerAvailabilityCommand, - ], + providers: [BackfillWorkflowManualTriggerAvailabilityCommand], + exports: [BackfillWorkflowManualTriggerAvailabilityCommand], }) export class V1_7_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 9d952e0524b79..ba25260bbb914 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -19,7 +19,9 @@ import { AddEnqueuedStatusToWorkflowRunCommand } from 'src/database/commands/upg import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command'; import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command'; import { MigrateWorkflowRunStatesCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-migrate-workflow-run-state.command'; +import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-deduplicate-unique-fields.command'; import { MigrateWorkflowStepFilterOperandValueCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-workflow-step-filter-operand-value'; +import { RegeneratePersonSearchVectorWithPhonesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-regenerate-person-search-vector-with-phones.command'; import { AddEnqueuedStatusToWorkflowRunV2Command } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-enqueued-status-to-workflow-run-v2.command'; import { AddNextStepIdsToWorkflowVersionTriggers } from 'src/database/commands/upgrade-version-command/1-2/1-2-add-next-step-ids-to-workflow-version-triggers.command'; import { RemoveWorkflowRunsWithoutState } from 'src/database/commands/upgrade-version-command/1-2/1-2-remove-workflow-runs-without-state.command'; @@ -87,6 +89,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { // 1.10 Commands protected readonly migrateWorkflowStepFilterOperandValueCommand: MigrateWorkflowStepFilterOperandValueCommand, + protected readonly deduplicateUniqueFieldsCommand: DeduplicateUniqueFieldsCommand, + protected readonly regeneratePersonSearchVectorWithPhonesCommand: RegeneratePersonSearchVectorWithPhonesCommand, ) { super( workspaceRepository, @@ -181,7 +185,11 @@ export class UpgradeCommand extends UpgradeCommandRunner { }; const commands_1100: VersionCommands = { - beforeSyncMetadata: [this.migrateWorkflowStepFilterOperandValueCommand], + beforeSyncMetadata: [ + this.migrateWorkflowStepFilterOperandValueCommand, + this.deduplicateUniqueFieldsCommand, + this.regeneratePersonSearchVectorWithPhonesCommand, + ], afterSyncMetadata: [], }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts index 3d18cc0704ed2..e711437e16776 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts @@ -388,7 +388,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol const savedRecords = await repository.updateMany( partialRecordsToUpdateWithoutCreatedByUpdate.map((record) => ({ criteria: record.id, - partialEntity: record, + partialEntity: { ...record, deletedAt: null }, })), undefined, columnsToReturn, diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts index 2b69c6ce6da91..52818e718fc7d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts @@ -269,7 +269,10 @@ export class IndexMetadataService { queryRunner, }: { workspaceId: string; - objectMetadata: ObjectMetadataEntity; + objectMetadata: Pick< + ObjectMetadataEntity, + 'nameSingular' | 'isCustom' | 'id' + >; fieldMetadataToIndex: Partial[]; queryRunner?: QueryRunner; }) { diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts index 921ff96980f47..adaa1e87ba413 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-is-unique.decorator.ts @@ -19,7 +19,7 @@ export function WorkspaceIsUnique(): PropertyDecorator { const columns = [propertyKey.toString()]; metadataArgsStorage.addIndexes({ - name: `IDX_UNIQUE_${generateDeterministicIndexName([ + name: `IDX_${generateDeterministicIndexName([ convertClassNameToObjectMetadataName(target.constructor.name), ...columns, ])}`, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts index ce848ef505b25..18989fc56d097 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/__tests__/workspace-migration-index.factory.util.spec.ts @@ -46,9 +46,7 @@ describe('WorkspaceMigrationIndexFactory', () => { expect(firstMigration.indexes[0].columns).toEqual(['simpleField']); expect(firstMigration.indexes[0].type).toBe('BTREE'); expect(firstMigration.indexes[0].isUnique).toBe(true); - expect(firstMigration.indexes[0].where).toBe( - '"simpleField" != \'\' AND "deletedAt" IS NULL', - ); + expect(firstMigration.indexes[0].where).toBe('"simpleField" != \'\''); }); it('should create index migrations for relation fields', async () => { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts index faf79410bc957..014a6741a77c5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-builder/factories/utils/workspace-migration-index.factory.utils.ts @@ -88,7 +88,7 @@ export const createIndexMigration = async ( .filter(isDefined); const defaultWhereClause = indexMetadata.isUnique - ? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != '' AND "deletedAt" IS NULL` + ? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != ''` : null; return { diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts index 7cfccca755914..02baa3d77fba0 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts @@ -8,7 +8,7 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; -import { CalendarCreateCompanyAndContactAfterSyncJob } from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job'; +import { CalendarCreateCompanyAndPersonAfterSyncJob } from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job'; import { CalendarEventParticipantMatchParticipantJob } from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-match-participant.job'; import { CalendarEventParticipantPersonListener } from 'src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-person.listener'; import { CalendarEventParticipantWorkspaceMemberListener } from 'src/modules/calendar/calendar-event-participant-manager/listeners/calendar-event-participant-workspace-member.listener'; @@ -28,7 +28,7 @@ import { MatchParticipantModule } from 'src/modules/match-participant/match-part ], providers: [ CalendarEventParticipantService, - CalendarCreateCompanyAndContactAfterSyncJob, + CalendarCreateCompanyAndPersonAfterSyncJob, CalendarEventParticipantMatchParticipantJob, CalendarEventParticipantListener, CalendarEventParticipantPersonListener, diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job.ts index 6a83850f3f468..407ff8c1602fc 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job.ts @@ -9,9 +9,9 @@ import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/com import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity'; import { type CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; -import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; +import { CreateCompanyAndPersonService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; -export type CalendarCreateCompanyAndContactAfterSyncJobData = { +export type CalendarCreateCompanyAndPersonAfterSyncJobData = { workspaceId: string; calendarChannelId: string; }; @@ -20,15 +20,15 @@ export type CalendarCreateCompanyAndContactAfterSyncJobData = { queueName: MessageQueue.calendarQueue, scope: Scope.REQUEST, }) -export class CalendarCreateCompanyAndContactAfterSyncJob { +export class CalendarCreateCompanyAndPersonAfterSyncJob { constructor( private readonly twentyORMManager: TwentyORMManager, - private readonly createCompanyAndContactService: CreateCompanyAndContactService, + private readonly createCompanyAndPersonService: CreateCompanyAndPersonService, ) {} - @Process(CalendarCreateCompanyAndContactAfterSyncJob.name) + @Process(CalendarCreateCompanyAndPersonAfterSyncJob.name) async handle( - data: CalendarCreateCompanyAndContactAfterSyncJobData, + data: CalendarCreateCompanyAndPersonAfterSyncJobData, ): Promise { const { workspaceId, calendarChannelId } = data; @@ -87,7 +87,7 @@ export class CalendarCreateCompanyAndContactAfterSyncJob { ], }); - await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( + await this.createCompanyAndPersonService.createCompaniesAndPeopleAndUpdateParticipants( connectedAccount, calendarEventParticipantsWithoutPersonIdAndWorkspaceMemberId, workspaceId, diff --git a/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts b/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts index 28fef31c9fe4b..4af3b3782c924 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/contact-creation-manager.module.ts @@ -7,9 +7,9 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { AutoCompaniesAndContactsCreationCalendarChannelListener } from 'src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-calendar-channel.listener'; import { AutoCompaniesAndContactsCreationMessageChannelListener } from 'src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-message-channel.listener'; -import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; +import { CreateCompanyAndPersonService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; import { CreateCompanyService } from 'src/modules/contact-creation-manager/services/create-company.service'; -import { CreateContactService } from 'src/modules/contact-creation-manager/services/create-contact.service'; +import { CreatePersonService } from 'src/modules/contact-creation-manager/services/create-person.service'; @Module({ imports: [ @@ -19,11 +19,11 @@ import { CreateContactService } from 'src/modules/contact-creation-manager/servi ], providers: [ CreateCompanyService, - CreateContactService, - CreateCompanyAndContactService, + CreatePersonService, + CreateCompanyAndPersonService, AutoCompaniesAndContactsCreationMessageChannelListener, AutoCompaniesAndContactsCreationCalendarChannelListener, ], - exports: [CreateCompanyAndContactService], + exports: [CreateCompanyAndPersonService], }) export class ContactCreationManagerModule {} diff --git a/packages/twenty-server/src/modules/contact-creation-manager/jobs/create-company-and-contact.job.ts b/packages/twenty-server/src/modules/contact-creation-manager/jobs/create-company-and-contact.job.ts index 6075fb3b491f9..2584960ad3d28 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/jobs/create-company-and-contact.job.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/jobs/create-company-and-contact.job.ts @@ -3,7 +3,7 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { type FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; -import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; +import { CreateCompanyAndPersonService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; export type CreateCompanyAndContactJobData = { workspaceId: string; @@ -18,14 +18,14 @@ export type CreateCompanyAndContactJobData = { @Processor(MessageQueue.contactCreationQueue) export class CreateCompanyAndContactJob { constructor( - private readonly createCompanyAndContactService: CreateCompanyAndContactService, + private readonly createCompanyAndPersonService: CreateCompanyAndPersonService, ) {} @Process(CreateCompanyAndContactJob.name) async handle(data: CreateCompanyAndContactJobData): Promise { const { workspaceId, connectedAccount, contactsToCreate, source } = data; - await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( + await this.createCompanyAndPersonService.createCompaniesAndPeopleAndUpdateParticipants( connectedAccount, contactsToCreate.map((contact) => ({ handle: contact.handle, diff --git a/packages/twenty-server/src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-calendar-channel.listener.ts b/packages/twenty-server/src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-calendar-channel.listener.ts index 00a98522efdc1..46c473b1f74d4 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-calendar-channel.listener.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/listeners/auto-companies-and-contacts-creation-calendar-channel.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 { type ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; import { objectRecordChangedProperties } 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'; @@ -7,12 +9,10 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; import { - CalendarCreateCompanyAndContactAfterSyncJob, - type CalendarCreateCompanyAndContactAfterSyncJobData, + CalendarCreateCompanyAndPersonAfterSyncJob, + CalendarCreateCompanyAndPersonAfterSyncJobData, } from 'src/modules/calendar/calendar-event-participant-manager/jobs/calendar-create-company-and-contact-after-sync.job'; import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.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 AutoCompaniesAndContactsCreationCalendarChannelListener { @@ -36,8 +36,8 @@ export class AutoCompaniesAndContactsCreationCalendarChannelListener { ).includes('isContactAutoCreationEnabled') && eventPayload.properties.after.isContactAutoCreationEnabled ) { - return this.messageQueueService.add( - CalendarCreateCompanyAndContactAfterSyncJob.name, + return this.messageQueueService.add( + CalendarCreateCompanyAndPersonAfterSyncJob.name, { workspaceId: payload.workspaceId, calendarChannelId: eventPayload.recordId, diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company-and-contact.service.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company-and-contact.service.spec.ts new file mode 100644 index 0000000000000..dbffba4b321a2 --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company-and-contact.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; +import { CreateCompanyAndPersonService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; +import { CreateCompanyService } from 'src/modules/contact-creation-manager/services/create-company.service'; +import { CreatePersonService } from 'src/modules/contact-creation-manager/services/create-person.service'; +import { type Contact } from 'src/modules/contact-creation-manager/types/contact.type'; +import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; + +describe('CreateCompanyAndPersonService', () => { + let service: CreateCompanyAndPersonService; + + const mockConnectedAccount = { + id: 'connected-account-1', + accountOwner: { + id: 'workspace-member-1', + }, + } as unknown as ConnectedAccountWorkspaceEntity; + + beforeEach(async () => { + const mockCreateCompaniesService = { + createOrRestoreCompanies: jest.fn(), + }; + const mockCreatePersonService = { + restorePeople: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateCompanyAndPersonService, + { + provide: CreateCompanyService, + useValue: mockCreateCompaniesService, + }, + { + provide: CreatePersonService, + useValue: mockCreatePersonService, + }, + { + provide: TwentyORMGlobalManager, + useValue: {}, + }, + { + provide: ExceptionHandlerService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get( + CreateCompanyAndPersonService, + ); + }); + + describe('computeContactsThatNeedPersonCreateAndRestoreAndWorkDomainNamesToCreate', () => { + const mockContacts: Contact[] = [ + { + handle: 'john.doe@company.com', + displayName: 'John Doe', + }, + { + handle: 'jane.smith@company.com', + displayName: 'Jane Smith', + }, + { + handle: 'personal@email.com', + displayName: 'Personal Contact', + }, + ]; + + const mockExistingPeople: PersonWorkspaceEntity[] = [ + { + id: 'soft-deleted-person-1', + emails: { + primaryEmail: 'john.doe@company.com', + additionalEmails: null, + }, + deletedAt: new Date(), + } as unknown as PersonWorkspaceEntity, + { + id: 'soft-deleted-person-2', + emails: { + primaryEmail: 'different@company.com', + additionalEmails: ['jane.smith@company.com'], + }, + deletedAt: new Date(), + } as unknown as PersonWorkspaceEntity, + { + id: 'active-person-3', + emails: { + primaryEmail: 'active@company.com', + additionalEmails: null, + }, + deletedAt: null, + } as unknown as PersonWorkspaceEntity, + ]; + + it('should identify contacts that need person creation for new contacts', () => { + const result = + service.computeContactsThatNeedPersonCreateAndRestoreAndWorkDomainNamesToCreate( + mockContacts, + mockExistingPeople, + FieldActorSource.CALENDAR, + mockConnectedAccount, + ); + + expect(result.contactsThatNeedPersonCreate).toHaveLength(1); + expect(result.contactsThatNeedPersonCreate[0].handle).toBe( + 'personal@email.com', + ); + }); + + it('should identify contacts that need person restoration for soft-deleted contacts', () => { + const result = + service.computeContactsThatNeedPersonCreateAndRestoreAndWorkDomainNamesToCreate( + mockContacts, + mockExistingPeople, + FieldActorSource.CALENDAR, + mockConnectedAccount, + ); + + expect(result.contactsThatNeedPersonRestore).toHaveLength(2); + expect( + result.contactsThatNeedPersonRestore.map((c) => c.handle), + ).toContain('john.doe@company.com'); + expect( + result.contactsThatNeedPersonRestore.map((c) => c.handle), + ).toContain('jane.smith@company.com'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts index e7de2c97e91c7..05dbfabe10221 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/__tests__/create-company.service.spec.ts @@ -46,6 +46,13 @@ describe('CreateCompanyService', () => { provider: ConnectedAccountProvider.GOOGLE, }, }; + const companyToRestore: CompanyToCreate = { + domainName: 'soft-deleted-company.com', + createdBySource: FieldActorSource.MANUAL, + createdByContext: { + provider: ConnectedAccountProvider.GOOGLE, + }, + }; const inputForCompanyToCreate1 = { address: { addressCity: undefined, @@ -89,6 +96,7 @@ describe('CreateCompanyService', () => { find: jest.fn(), save: jest.fn(), maximum: jest.fn().mockResolvedValue(0), + updateMany: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -135,10 +143,14 @@ describe('CreateCompanyService', () => { mockCompanyRepository.find.mockResolvedValue([]); // it is useless to check results here, we can only check the input it was called with mockCompanyRepository.save.mockResolvedValue([]); + + mockCompanyRepository.updateMany.mockResolvedValue({ + raw: [], + }); }); it('should successfully create a company', async () => { - await service.createCompanies([companyToCreate1], workspaceId); + await service.createOrRestoreCompanies([companyToCreate1], workspaceId); expect(mockCompanyRepository.find).toHaveBeenCalled(); expect(mockCompanyRepository.save).toHaveBeenCalledWith([ @@ -147,7 +159,7 @@ describe('CreateCompanyService', () => { }); it('should successfully two companies', async () => { - await service.createCompanies( + await service.createOrRestoreCompanies( [companyToCreate1, companyToCreate2], workspaceId, ); @@ -160,7 +172,7 @@ describe('CreateCompanyService', () => { }); it('should create only one of example.com & example.com/ ', async () => { - await service.createCompanies( + await service.createOrRestoreCompanies( [companyToCreate1, companyToCreate1withSlash], workspaceId, ); @@ -187,13 +199,59 @@ describe('CreateCompanyService', () => { }, ]); mockCompanyRepository.save.mockResolvedValue([]); + mockCompanyRepository.updateMany.mockResolvedValue({ + raw: [], + }); }); it('should not create a company if it already exists', async () => { - await service.createCompanies([companyToCreateExisting], workspaceId); + await service.createOrRestoreCompanies( + [companyToCreateExisting], + workspaceId, + ); expect(mockCompanyRepository.find).toHaveBeenCalled(); - expect(mockCompanyRepository.save).not.toHaveBeenCalled(); + expect(mockCompanyRepository.save).toHaveBeenCalledWith([]); + }); + }); + + describe('With existing companies and some deleted', () => { + beforeEach(() => { + mockCompanyRepository.find.mockResolvedValue([ + { + id: 'soft-deleted-company-1', + domainName: { primaryLinkUrl: 'https://soft-deleted-company.com' }, + deletedAt: new Date(), + }, + ]); + mockCompanyRepository.save.mockResolvedValue([]); + mockCompanyRepository.updateMany.mockResolvedValue({ + raw: [ + { + id: 'soft-deleted-company-1', + domainNamePrimaryLinkUrl: 'https://soft-deleted-company.com', + }, + ], + }); + }); + + it('should restore the soft deleted company', async () => { + await service.createOrRestoreCompanies([companyToRestore], workspaceId); + + expect(mockCompanyRepository.find).toHaveBeenCalled(); + expect(mockCompanyRepository.save).toHaveBeenCalledWith([]); + expect(mockCompanyRepository.updateMany).toHaveBeenCalledWith( + [ + { + criteria: 'soft-deleted-company-1', + partialEntity: { + deletedAt: null, + }, + }, + ], + undefined, + ['domainNamePrimaryLinkUrl', 'id'], + ); }); }); }); 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 8c4fb40ae3e84..93fd6accccaeb 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,12 @@ import { Injectable } from '@nestjs/common'; -import { isNonEmptyString } from '@sniptt/guards'; +import { isNonEmptyString, isNull } from '@sniptt/guards'; import chunk from 'lodash.chunk'; import compact from 'lodash.compact'; +import { ConnectedAccountProvider } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type DeepPartial } from 'typeorm'; +import { v4 } from 'uuid'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { type FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; @@ -11,26 +14,27 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global. import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { CONTACTS_CREATION_BATCH_SIZE } from 'src/modules/contact-creation-manager/constants/contacts-creation-batch-size.constant'; import { CreateCompanyService } from 'src/modules/contact-creation-manager/services/create-company.service'; -import { CreateContactService } from 'src/modules/contact-creation-manager/services/create-contact.service'; +import { CreatePersonService } from 'src/modules/contact-creation-manager/services/create-person.service'; import { type 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 { filterOutContactsThatBelongToSelfOrWorkspaceMembers } from 'src/modules/contact-creation-manager/utils/filter-out-contacts-that-belong-to-self-or-workspace-members.util'; import { getDomainNameFromHandle } from 'src/modules/contact-creation-manager/utils/get-domain-name-from-handle.util'; +import { getFirstNameAndLastNameFromHandleAndDisplayName } from 'src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.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 { computeDisplayName } from 'src/utils/compute-display-name'; import { isWorkDomain, isWorkEmail } from 'src/utils/is-work-email'; - @Injectable() -export class CreateCompanyAndContactService { +export class CreateCompanyAndPersonService { constructor( - private readonly createContactService: CreateContactService, + private readonly createPersonService: CreatePersonService, private readonly createCompaniesService: CreateCompanyService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly exceptionHandlerService: ExceptionHandlerService, ) {} - private async createCompaniesAndPeople( + async createCompaniesAndPeople( connectedAccount: ConnectedAccountWorkspaceEntity, contactsToCreate: Contact[], workspaceId: string, @@ -57,15 +61,15 @@ export class CreateCompanyAndContactService { const workspaceMembers = await workspaceMemberRepository.find(); - const contactsToCreateFromOtherCompanies = - filterOutSelfAndContactsFromCompanyOrWorkspace( + const peopleToCreateFromOtherCompanies = + filterOutContactsThatBelongToSelfOrWorkspaceMembers( contactsToCreate, connectedAccount, workspaceMembers, ); const { uniqueContacts, uniqueHandles } = getUniqueContactsAndHandles( - contactsToCreateFromOtherCompanies, + peopleToCreateFromOtherCompanies, ); if (uniqueHandles.length === 0) { @@ -77,96 +81,62 @@ export class CreateCompanyAndContactService { emails: uniqueHandles, }); - const alreadyCreatedContacts = await queryBuilder + const alreadyCreatedPeople = await queryBuilder .orderBy('person.createdAt', 'ASC') + .withDeleted() .getMany(); - 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( - participant.handle.toLowerCase(), - ) && participant.handle.includes('@'), - ); - - const filteredContactsToCreateWithCompanyDomainNames = - filteredContactsToCreate?.map((participant) => ({ - handle: participant.handle, - displayName: participant.displayName, - companyDomainName: isWorkEmail(participant.handle) - ? getDomainNameFromHandle(participant.handle) - : undefined, - })); - - const domainNamesToCreate = compact( - filteredContactsToCreateWithCompanyDomainNames - .filter((participant) => participant.companyDomainName) - .map((participant) => ({ - domainName: participant.companyDomainName, - createdBySource: source, - createdByWorkspaceMember: connectedAccount.accountOwner, - })), - ); + const { + contactsThatNeedPersonCreate, + contactsThatNeedPersonRestore, + workDomainNamesToCreate, + shouldCreateOrRestorePeopleByHandleMap, + } = + this.computeContactsThatNeedPersonCreateAndRestoreAndWorkDomainNamesToCreate( + uniqueContacts, + alreadyCreatedPeople, + source, + connectedAccount, + ); - const workDomainNamesToCreate = domainNamesToCreate.filter( - (domainName) => - domainName?.domainName && isWorkDomain(domainName.domainName), - ); + const companiesMap = + await this.createCompaniesService.createOrRestoreCompanies( + workDomainNamesToCreate, + workspaceId, + ); - const workDomainNamesToCreateFormatted = workDomainNamesToCreate.map( - (domainName) => ({ - ...domainName, - createdBySource: source, - createdByWorkspaceMember: connectedAccount.accountOwner, - createdByContext: { + const peopleToCreate = this.formatPeopleToCreateFromContacts({ + contactsToCreate: contactsThatNeedPersonCreate, + createdBy: { + source: source, + workspaceMember: connectedAccount.accountOwner, + context: { provider: connectedAccount.provider, }, - }), - ); + }, + companiesMap, + }); - const companiesObject = await this.createCompaniesService.createCompanies( - workDomainNamesToCreateFormatted, + const createdPeople = await this.createPersonService.createPeople( + peopleToCreate, workspaceId, ); - const formattedContactsToCreate = - filteredContactsToCreateWithCompanyDomainNames.map((contact) => ({ - handle: contact.handle, - displayName: contact.displayName, - companyId: isNonEmptyString(contact.companyDomainName) - ? companiesObject[contact.companyDomainName] - : undefined, - createdBySource: source, - createdByWorkspaceMember: connectedAccount.accountOwner, - createdByContext: { - provider: connectedAccount.provider, - }, - })); + const peopleToRestore = this.formatPeopleToRestoreFromContacts({ + contactsToRestore: contactsThatNeedPersonRestore, + companiesMap, + shouldCreateOrRestorePeopleByHandleMap, + }); - return this.createContactService.createPeople( - formattedContactsToCreate, + const restoredPeople = await this.createPersonService.restorePeople( + peopleToRestore, workspaceId, ); + + return { ...createdPeople, ...restoredPeople }; } - async createCompaniesAndContactsAndUpdateParticipants( + async createCompaniesAndPeopleAndUpdateParticipants( connectedAccount: ConnectedAccountWorkspaceEntity, contactsToCreate: Contact[], workspaceId: string, @@ -177,7 +147,6 @@ export class CreateCompanyAndContactService { CONTACTS_CREATION_BATCH_SIZE, ); - // In some jobs the accountOwner is not populated if (!connectedAccount.accountOwner) { const workspaceMemberRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( @@ -217,4 +186,195 @@ export class CreateCompanyAndContactService { } } } + + computeContactsThatNeedPersonCreateAndRestoreAndWorkDomainNamesToCreate( + uniqueContacts: Contact[], + alreadyCreatedPeople: PersonWorkspaceEntity[], + source: FieldActorSource, + connectedAccount: ConnectedAccountWorkspaceEntity, + ) { + const shouldCreateOrRestorePeopleByHandleMap = new Map< + string, + { existingPerson: PersonWorkspaceEntity } + >(); + + for (const contact of uniqueContacts) { + if (!contact.handle.includes('@')) { + continue; + } + + const existingPersonOnPrimaryEmail = alreadyCreatedPeople.find( + (person) => { + return ( + isNonEmptyString(person.emails?.primaryEmail) && + person.emails.primaryEmail.toLowerCase() === + contact.handle.toLowerCase() + ); + }, + ); + + if (isDefined(existingPersonOnPrimaryEmail)) { + shouldCreateOrRestorePeopleByHandleMap.set( + contact.handle.toLowerCase(), + { + existingPerson: existingPersonOnPrimaryEmail, + }, + ); + continue; + } + + const existingPersonOnAdditionalEmails = alreadyCreatedPeople.find( + (person) => { + return ( + Array.isArray(person.emails?.additionalEmails) && + person.emails.additionalEmails.some( + (email) => email.toLowerCase() === contact.handle.toLowerCase(), + ) + ); + }, + ); + + if (!isDefined(existingPersonOnAdditionalEmails)) continue; + + shouldCreateOrRestorePeopleByHandleMap.set(contact.handle.toLowerCase(), { + existingPerson: existingPersonOnAdditionalEmails, + }); + } + + const contactsThatNeedPersonCreate = uniqueContacts.filter( + (contact) => + !shouldCreateOrRestorePeopleByHandleMap.has( + contact.handle.toLowerCase(), + ), + ); + + const contactsThatNeedPersonRestore = uniqueContacts.filter((contact) => { + const existingPerson = shouldCreateOrRestorePeopleByHandleMap.get( + contact.handle.toLowerCase(), + )?.existingPerson; + + if (!isDefined(existingPerson)) { + return false; + } + + return !isNull(existingPerson.deletedAt); + }); + + const workDomainNamesToCreate = compact( + [...contactsThatNeedPersonCreate, ...contactsThatNeedPersonRestore] + .map((contact) => { + const companyDomainName = isWorkEmail(contact.handle) + ? getDomainNameFromHandle(contact.handle) + : undefined; + + if (!isDefined(companyDomainName) || !isWorkDomain(companyDomainName)) + return undefined; + + return { + domainName: companyDomainName, + createdBySource: source, + createdByWorkspaceMember: connectedAccount.accountOwner, + createdByContext: { + provider: connectedAccount.provider, + }, + }; + }) + .filter(isDefined), + ); + + return { + contactsThatNeedPersonCreate, + contactsThatNeedPersonRestore, + workDomainNamesToCreate, + shouldCreateOrRestorePeopleByHandleMap, + }; + } + + formatPeopleToCreateFromContacts({ + contactsToCreate, + createdBy, + companiesMap, + }: { + contactsToCreate: { + handle: string; + displayName: string; + }[]; + createdBy: { + source: FieldActorSource; + workspaceMember?: WorkspaceMemberWorkspaceEntity | null; + context: { + provider: ConnectedAccountProvider; + }; + }; + companiesMap: Record; + }): Partial[] { + return contactsToCreate.map((contact) => { + const id = v4(); + + const { handle, displayName } = contact; + + const { firstName, lastName } = + getFirstNameAndLastNameFromHandleAndDisplayName(handle, displayName); + const createdByName = computeDisplayName(createdBy.workspaceMember?.name); + + const companyId = companiesMap[getDomainNameFromHandle(handle)]; + + return { + id, + emails: { + primaryEmail: handle.toLowerCase(), + additionalEmails: null, + }, + name: { + firstName, + lastName, + }, + companyId, + createdBy: { + source: createdBy.source, + workspaceMemberId: createdBy.workspaceMember?.id ?? null, + name: createdByName, + context: createdBy.context, + }, + }; + }); + } + + formatPeopleToRestoreFromContacts({ + contactsToRestore, + companiesMap, + shouldCreateOrRestorePeopleByHandleMap, + }: { + contactsToRestore: { + handle: string; + displayName: string; + }[]; + companiesMap: Record; + shouldCreateOrRestorePeopleByHandleMap: Map< + string, + { existingPerson: PersonWorkspaceEntity | undefined } + >; + }): { personId: string; companyId: string | undefined }[] { + const peopleToRestore = []; + + for (const contact of contactsToRestore) { + const { handle } = contact; + + const existingPerson = shouldCreateOrRestorePeopleByHandleMap.get( + handle.toLowerCase(), + )?.existingPerson; + + if (!isDefined(existingPerson) || isNull(existingPerson.deletedAt)) + continue; + + const companyId = companiesMap[getDomainNameFromHandle(handle)]; + + peopleToRestore.push({ + personId: existingPerson.id, + companyId, + }); + } + + return peopleToRestore; + } } diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts index 9c44cc4ca4ca8..856ca3e68ec83 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/create-company.service.ts @@ -4,7 +4,10 @@ import axios, { type AxiosInstance } from 'axios'; import uniqBy from 'lodash.uniqby'; import { TWENTY_COMPANIES_BASE_URL } from 'twenty-shared/constants'; import { type ConnectedAccountProvider } from 'twenty-shared/types'; -import { lowercaseUrlOriginAndRemoveTrailingSlash } from 'twenty-shared/utils'; +import { + isDefined, + lowercaseUrlOriginAndRemoveTrailingSlash, +} from 'twenty-shared/utils'; import { type DeepPartial, ILike } from 'typeorm'; import { type FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; @@ -35,7 +38,7 @@ export class CreateCompanyService { }); } - async createCompanies( + async createOrRestoreCompanies( companies: CompanyToCreate[], workspaceId: string, ): Promise<{ @@ -54,7 +57,6 @@ export class CreateCompanyService { }, ); - // Remove trailing slash from domain names const companiesWithoutTrailingSlash = companies.map((company) => ({ ...company, domainName: company.domainName @@ -62,7 +64,6 @@ export class CreateCompanyService { : undefined, })); - // Avoid creating duplicate companies, e.g. example.com and example.com/ const uniqueCompanies = uniqBy(companiesWithoutTrailingSlash, 'domainName'); const conditions = uniqueCompanies.map((companyToCreate) => ({ domainName: { @@ -70,13 +71,12 @@ export class CreateCompanyService { }, })); - // Find existing companies const existingCompanies = await companyRepository.find({ where: conditions, + withDeleted: true, }); const existingCompanyIdsMap = this.createCompanyMap(existingCompanies); - // Filter out companies that already exist const newCompaniesToCreate = uniqueCompanies.filter( (company) => !existingCompanies.some( @@ -87,11 +87,15 @@ export class CreateCompanyService { ), ); - if (newCompaniesToCreate.length === 0) { + const companiesToRestore = this.filterCompaniesToRestore( + uniqueCompanies, + existingCompanies, + ); + + if (newCompaniesToCreate.length === 0 && companiesToRestore.length === 0) { return existingCompanyIdsMap; } - // Retrieve the last company position let lastCompanyPosition = await this.getLastCompanyPosition(companyRepository); const newCompaniesData = await Promise.all( @@ -100,17 +104,67 @@ export class CreateCompanyService { ), ); - // Create new companies const createdCompanies = await companyRepository.save(newCompaniesData); - const createdCompanyIdsMap = this.createCompanyMap(createdCompanies); + const restoredCompanies = await companyRepository.updateMany( + companiesToRestore.map((company) => { + return { + criteria: company.id, + partialEntity: { + deletedAt: null, + }, + }; + }), + undefined, + ['domainNamePrimaryLinkUrl', 'id'], + ); + + const formattedRestoredCompanies = restoredCompanies.raw.map( + (row: { id: string; domainNamePrimaryLinkUrl: string }) => { + return { + id: row.id, + domainName: { + primaryLinkUrl: row.domainNamePrimaryLinkUrl, + }, + }; + }, + ); return { ...existingCompanyIdsMap, - ...createdCompanyIdsMap, + ...(createdCompanies.length > 0 + ? this.createCompanyMap(createdCompanies) + : {}), + ...(formattedRestoredCompanies.length > 0 + ? this.createCompanyMap(formattedRestoredCompanies) + : {}), }; } + private filterCompaniesToRestore( + uniqueCompanies: CompanyToCreate[], + existingCompanies: CompanyWorkspaceEntity[], + ) { + return uniqueCompanies + .map((company) => { + const existingCompany = existingCompanies.find( + (existingCompany) => + existingCompany.domainName && + extractDomainFromLink(existingCompany.domainName.primaryLinkUrl) === + company.domainName, + ); + + return isDefined(existingCompany) + ? { + domainName: company.domainName, + id: existingCompany.id, + deletedAt: null, + } + : undefined; + }) + .filter(isDefined); + } + private async prepareCompanyData( company: CompanyToCreate, position: number, @@ -142,7 +196,9 @@ export class CreateCompanyService { }; } - private createCompanyMap(companies: DeepPartial[]) { + private createCompanyMap( + companies: Pick[], + ) { return companies.reduce( (acc, company) => { if (!company.domainName?.primaryLinkUrl || !company.id) { diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-contact.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-contact.service.ts deleted file mode 100644 index be68ebcb6397a..0000000000000 --- a/packages/twenty-server/src/modules/contact-creation-manager/services/create-contact.service.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type ConnectedAccountProvider } from 'twenty-shared/types'; -import { type DeepPartial } from 'typeorm'; -import { v4 } from 'uuid'; - -import { type FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; -import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { getFirstNameAndLastNameFromHandleAndDisplayName } from 'src/modules/contact-creation-manager/utils/get-first-name-and-last-name-from-handle-and-display-name.util'; -import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; -import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; -import { computeDisplayName } from 'src/utils/compute-display-name'; - -type ContactToCreate = { - handle: string; - displayName: string; - companyId?: string; - createdBySource: FieldActorSource; - createdByWorkspaceMember?: WorkspaceMemberWorkspaceEntity | null; - createdByContext?: { - provider?: ConnectedAccountProvider; - }; -}; - -@Injectable() -export class CreateContactService { - constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - ) {} - - private formatContacts( - contactsToCreate: ContactToCreate[], - lastPersonPosition: number, - ): DeepPartial[] { - return contactsToCreate.map((contact) => { - const id = v4(); - - const { - handle, - displayName, - companyId, - createdBySource, - createdByWorkspaceMember, - createdByContext, - } = contact; - - const { firstName, lastName } = - getFirstNameAndLastNameFromHandleAndDisplayName(handle, displayName); - const createdByName = computeDisplayName(createdByWorkspaceMember?.name); - - return { - id, - emails: { - primaryEmail: handle.toLowerCase(), - additionalEmails: null, - }, - name: { - firstName, - lastName, - }, - companyId, - createdBy: { - source: createdBySource, - workspaceMemberId: contact.createdByWorkspaceMember?.id, - name: createdByName, - context: createdByContext, - }, - position: ++lastPersonPosition, - }; - }); - } - - public async createPeople( - contactsToCreate: ContactToCreate[], - workspaceId: string, - ): Promise[]> { - if (contactsToCreate.length === 0) return []; - - const personRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - PersonWorkspaceEntity, - { - shouldBypassPermissionChecks: true, - }, - ); - - const lastPersonPosition = - await this.getLastPersonPosition(personRepository); - - const formattedContacts = this.formatContacts( - contactsToCreate, - lastPersonPosition, - ); - - return personRepository.save(formattedContacts, undefined); - } - - private async getLastPersonPosition( - personRepository: WorkspaceRepository, - ): Promise { - const lastPersonPosition = await personRepository.maximum( - 'position', - undefined, - ); - - return lastPersonPosition ?? 0; - } -} diff --git a/packages/twenty-server/src/modules/contact-creation-manager/services/create-person.service.ts b/packages/twenty-server/src/modules/contact-creation-manager/services/create-person.service.ts new file mode 100644 index 0000000000000..7a8747e55774f --- /dev/null +++ b/packages/twenty-server/src/modules/contact-creation-manager/services/create-person.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; + +import { DeepPartial } from 'typeorm'; + +import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; + +@Injectable() +export class CreatePersonService { + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + public async createPeople( + peopleToCreate: Partial[], + workspaceId: string, + ): Promise[]> { + if (peopleToCreate.length === 0) return []; + + const personRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + PersonWorkspaceEntity, + { + shouldBypassPermissionChecks: true, + }, + ); + + const lastPersonPosition = + await this.getLastPersonPosition(personRepository); + + const createdPeople = await personRepository.insert( + peopleToCreate.map((person, index) => ({ + ...person, + position: lastPersonPosition + index, + })), + undefined, + ['companyId', 'id'], + ); + + return createdPeople.raw; + } + + public async restorePeople( + people: { personId: string; companyId: string | undefined }[], + workspaceId: string, + ): Promise[]> { + if (people.length === 0) { + return []; + } + + const personRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + PersonWorkspaceEntity, + { + shouldBypassPermissionChecks: true, + }, + ); + + const restoredPeople = await personRepository.updateMany( + people.map(({ personId, companyId }) => ({ + criteria: personId, + partialEntity: { + deletedAt: null, + companyId, + }, + })), + undefined, + ['companyId', 'id'], + ); + + return restoredPeople.raw; + } + + private async getLastPersonPosition( + personRepository: WorkspaceRepository, + ): Promise { + const lastPersonPosition = await personRepository.maximum( + 'position', + undefined, + ); + + return lastPersonPosition ?? 0; + } +} diff --git a/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts b/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-that-belong-to-self-or-workspace-members.util.ts similarity index 96% rename from packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts rename to packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-that-belong-to-self-or-workspace-members.util.ts index 6de2dde6a269c..7db5d9b4da5a8 100644 --- a/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-from-company-or-workspace.util.ts +++ b/packages/twenty-server/src/modules/contact-creation-manager/utils/filter-out-contacts-that-belong-to-self-or-workspace-members.util.ts @@ -4,7 +4,7 @@ import { getDomainNameFromHandle } from 'src/modules/contact-creation-manager/ut import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { isWorkDomain } from 'src/utils/is-work-email'; -export function filterOutSelfAndContactsFromCompanyOrWorkspace( +export function filterOutContactsThatBelongToSelfOrWorkspaceMembers( contacts: Contact[], connectedAccount: ConnectedAccountWorkspaceEntity, workspaceMembers: WorkspaceMemberWorkspaceEntity[], 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 index 361f7e6102a79..167b0cf0e055c 100644 --- 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 @@ -8,6 +8,7 @@ exports[`addPersonEmailFiltersToQueryBuilder case-insensitive email normalizatio "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -24,6 +25,10 @@ exports[`addPersonEmailFiltersToQueryBuilder case-insensitive email normalizatio ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.emailsAdditionalEmails @> :email0::jsonb", @@ -57,6 +62,7 @@ exports[`addPersonEmailFiltersToQueryBuilder emails with empty exclusion array: "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -72,6 +78,10 @@ exports[`addPersonEmailFiltersToQueryBuilder emails with empty exclusion array: ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.emailsAdditionalEmails @> :email0::jsonb", @@ -96,6 +106,7 @@ exports[`addPersonEmailFiltersToQueryBuilder empty emails array: should handle e "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -113,6 +124,10 @@ exports[`addPersonEmailFiltersToQueryBuilder empty emails array: should handle e "args": [], "method": "withDeleted", }, + { + "args": [], + "method": "withDeleted", + }, ] `; @@ -124,6 +139,7 @@ exports[`addPersonEmailFiltersToQueryBuilder multiple emails with exclusions: sh "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -140,6 +156,10 @@ exports[`addPersonEmailFiltersToQueryBuilder multiple emails with exclusions: sh ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.id NOT IN (:...excludePersonIds)", @@ -190,6 +210,7 @@ exports[`addPersonEmailFiltersToQueryBuilder multiple emails without exclusions: "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -206,6 +227,10 @@ exports[`addPersonEmailFiltersToQueryBuilder multiple emails without exclusions: ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.emailsAdditionalEmails @> :email0::jsonb", @@ -239,6 +264,7 @@ exports[`addPersonEmailFiltersToQueryBuilder single email with person ID exclusi "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -254,6 +280,10 @@ exports[`addPersonEmailFiltersToQueryBuilder single email with person ID exclusi ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.id NOT IN (:...excludePersonIds)", @@ -294,6 +324,7 @@ exports[`addPersonEmailFiltersToQueryBuilder single email without exclusions: sh "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -309,6 +340,10 @@ exports[`addPersonEmailFiltersToQueryBuilder single email without exclusions: sh ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.emailsAdditionalEmails @> :email0::jsonb", @@ -333,6 +368,7 @@ exports[`addPersonEmailFiltersToQueryBuilder three emails with unique parameter "person.id", "person.emailsPrimaryEmail", "person.emailsAdditionalEmails", + "person.deletedAt", ], ], "method": "select", @@ -350,6 +386,10 @@ exports[`addPersonEmailFiltersToQueryBuilder three emails with unique parameter ], "method": "where", }, + { + "args": [], + "method": "withDeleted", + }, { "args": [ "person.emailsAdditionalEmails @> :email0::jsonb", 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 index cc77522c43c24..abc9377785841 100644 --- 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 @@ -30,10 +30,12 @@ export function addPersonEmailFiltersToQueryBuilder({ 'person.id', 'person.emailsPrimaryEmail', 'person.emailsAdditionalEmails', + 'person.deletedAt', ]) .where('LOWER(person.emailsPrimaryEmail) IN (:...emails)', { emails: normalizedEmails, - }); + }) + .withDeleted(); if (excludePersonIds.length > 0) { queryBuilder = queryBuilder.andWhere( diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts index 14f5cb234e64e..949bcdc3c6384 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/jobs/messaging-create-company-and-contact-after-sync.job.ts @@ -8,7 +8,7 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; -import { CreateCompanyAndContactService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; +import { CreateCompanyAndPersonService } from 'src/modules/contact-creation-manager/services/create-company-and-contact.service'; import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum'; import { MessageChannelContactAutoCreationPolicy, @@ -27,7 +27,7 @@ export class MessagingCreateCompanyAndContactAfterSyncJob { MessagingCreateCompanyAndContactAfterSyncJob.name, ); constructor( - private readonly createCompanyAndContactService: CreateCompanyAndContactService, + private readonly createCompanyAndPersonService: CreateCompanyAndPersonService, private readonly twentyORMManager: TwentyORMManager, ) {} @@ -36,7 +36,7 @@ export class MessagingCreateCompanyAndContactAfterSyncJob { data: MessagingCreateCompanyAndContactAfterSyncJobData, ): Promise { this.logger.log( - `create contacts and companies after sync for workspace ${data.workspaceId} and messageChannel ${data.messageChannelId}`, + `create people and companies after sync for workspace ${data.workspaceId} and messageChannel ${data.messageChannelId}`, ); const { workspaceId, messageChannelId } = data; @@ -100,7 +100,7 @@ export class MessagingCreateCompanyAndContactAfterSyncJob { }, }); - await this.createCompanyAndContactService.createCompaniesAndContactsAndUpdateParticipants( + await this.createCompanyAndPersonService.createCompaniesAndPeopleAndUpdateParticipants( connectedAccount, contactsToCreate, workspaceId, diff --git a/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts index 3f0623e8440b8..7db4d86fd8ecd 100644 --- a/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/upsert/upsert.integration-spec.ts @@ -171,60 +171,6 @@ describe('upsert (createMany with upsert:true)', () => { }); }); - it('should update soft-deleted records', async () => { - // Create a record - const createResponse = await makeGraphqlAPIRequest({ - query: createRecordsQuery, - variables: { - data: [ - { - firstUniqueTestField: 'softDeletedRecord', - secondUniqueTestField: 'softDeletedSecondField', - name: 'originalRecord', - }, - ], - upsert: false, - }, - }); - - const createdRecord = createResponse.body.data.createTestRecordObjects[0]; - - // Soft delete the record - await makeGraphqlAPIRequest({ - query: deleteRecordsQuery, - variables: { - filter: { - id: { - eq: createdRecord.id, - }, - }, - }, - }); - - const upsertResponse = await makeGraphqlAPIRequest({ - query: createRecordsQuery, - variables: { - data: [ - { - firstUniqueTestField: 'softDeletedRecord', - name: 'restoredRecord', - }, - ], - upsert: true, - }, - }); - - const upsertedRecord = upsertResponse.body.data.createTestRecordObjects[0]; - - expect(upsertedRecord).toEqual({ - id: createdRecord.id, - firstUniqueTestField: 'softDeletedRecord', - secondUniqueTestField: 'softDeletedSecondField', - name: 'restoredRecord', - deletedAt: expect.any(String), - }); - }); - it('should throw an error when multiple records with the same unique field values are found', async () => { await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -267,4 +213,47 @@ describe('upsert (createMany with upsert:true)', () => { 'BAD_USER_INPUT', ); }); + + it('should update and restore updated soft-deleted record', async () => { + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + firstUniqueTestField: 'softDeletedRecord', + secondUniqueTestField: 'softDeletedSecondField', + name: 'originalRecord', + }, + ], + upsert: false, + }, + }); + + const createdRecord = createResponse.body.data.createTestRecordObjects[0]; + + const deleteResponse = await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: createdRecord.id } }, + }, + }); + + const updateResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [{ id: createdRecord.id, name: 'updatedRecord' }], + upsert: true, + }, + }); + + expect( + deleteResponse.body.data.deleteTestRecordObjects[0].deletedAt, + ).toEqual(expect.any(String)); + expect( + updateResponse.body.data.createTestRecordObjects[0].deletedAt, + ).toBeNull(); + expect(updateResponse.body.data.createTestRecordObjects[0].id).toEqual( + createdRecord.id, + ); + }); });