Handle migration of Email to Emails fields (#6885)

This is the second PR on TWNTY-6261 which handlesdata migration of Email
field to Emails field.\
\
How to Test?\
Firstly make sure that you have completed the testing steps on first PR
then follow the below steps:

- Checkout to TWNTY-6261-emails-migrations branch
- Rebuild typescript using "npx nx build twenty-server"
- Run command "yarn command:prod upgrade-0.25" to do migration\
  \
  Loom Video:\

<https://www.loom.com/share/f82b8d29f8f64f92abe3c59c01147b45?sid=9f8ccc05-aa38-4c49-b139-fd0823066273>

**Testing Messaging Sync functionality:**

Please watch the below video to see that the synchronization of contacts
is working fine after migrating Email field to Emails field:\

<https://www.loom.com/share/400949464b244272b78c25e338cc6ab2?sid=103f6625-5933-4b99-9825-0fed33782f36>

**Question to the client**

should we rename email to emails here? in the DomainName PR, the name
did not change.

```typescript
  @WorkspaceField({
    standardId: PERSON_STANDARD_FIELD_IDS.email,
    type: FieldMetadataType.EMAILS,
    label: 'Email',
    description: 'Contact’s Email',
    icon: 'IconMail',
  })
  email: EmailsMetadata;
```

**Test Messaging Sync**

This pr will update messaging sync files so the changes shouldn't break
existing functionality of importing people and companies in the app.\
To test messaging sync you should follow the below steps:\
1. you need to connect a google account to see the importing
functionality. For this purpose you

have to create a project inside Google Cloud. But to make things easier
you can use the below credentials of an already created project. Put
them in .env of twenty-server package:

```properties
MESSAGING_PROVIDER_GMAIL_ENABLED=true
CALENDAR_PROVIDER_GOOGLE_ENABLED=true
AUTH_GOOGLE_ENABLED=true
AUTH_GOOGLE_CLIENT_ID=951231465939-h61tg6nkpkv1821qi899fjbj9looquto.apps.googleusercontent.com
AUTH_GOOGLE_CLIENT_SECRET=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
AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
MESSAGE_QUEUE_TYPE=bull-mq
```

Alternative env

```properties
MESSAGING_PROVIDER_GMAIL_ENABLED=true
CALENDAR_PROVIDER_GOOGLE_ENABLED=true
AUTH_GOOGLE_ENABLED=true
AUTH_GOOGLE_CLIENT_ID=622006708006-dc4n3vrtf3cs2h6k7hgbborudme7ku9l.apps.googleusercontent.com
AUTH_GOOGLE_CLIENT_SECRET=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
AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
MESSAGE_QUEUE_TYPE=bull-mq
```

1. Launch your worker with `npx nx run twenty-server:worker`
2. npx nx run twenty-server:command cron:messaging:messages-import
3. npx nx run twenty-server:command cron:messaging:message-list-fetch
4. npx nx run twenty-server:command
cron📆calendar-event-list-fetch
5. Run the app and navigate to Settings/Accounts then connect your
Google account

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
gitstart-app[bot]
2024-09-12 18:31:11 +02:00
committed by GitHub
parent 3548751be2
commit 31c02202bd
24 changed files with 577 additions and 62 deletions

View File

@@ -185,7 +185,11 @@ xLink
id
createdAt
city
email
emails
{
primaryEmail
additionalEmails
}
jobTitle
name
{

View File

@@ -41,7 +41,11 @@ describe('mapObjectMetadataToGraphQLQuery', () => {
firstName
lastName
}
email
emails
{
primaryEmail
additionalEmails
}
phone
createdAt
avatarUrl

View File

@@ -8,6 +8,7 @@ import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-dem
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { UpgradeTo0_24CommandModule } from 'src/database/commands/upgrade-version/0-24/0-24-upgrade-version.module';
import { UpgradeTo0_30CommandModule } from 'src/database/commands/upgrade-version/0-30/0-30-upgrade-version.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@@ -46,6 +47,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
DataSeedDemoWorkspaceModule,
WorkspaceMetadataVersionModule,
UpgradeTo0_24CommandModule,
UpgradeTo0_30CommandModule,
],
providers: [
DataSeedWorkspaceCommand,

View File

@@ -1,6 +1,5 @@
import { Command, CommandRunner, Option } from 'nest-commander';
import { SetCustomObjectIsSoftDeletableCommand } from 'src/database/commands/upgrade-version/0-24/0-24-set-custom-object-is-soft-deletable.command';
import { SetMessageDirectionCommand } from 'src/database/commands/upgrade-version/0-24/0-24-set-message-direction.command';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
@@ -16,7 +15,6 @@ export class UpgradeTo0_24Command extends CommandRunner {
constructor(
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly setMessagesDirectionCommand: SetMessageDirectionCommand,
private readonly setCustomObjectIsSoftDeletableCommand: SetCustomObjectIsSoftDeletableCommand,
) {
super();
}
@@ -40,6 +38,5 @@ export class UpgradeTo0_24Command extends CommandRunner {
force: true,
});
await this.setMessagesDirectionCommand.run(passedParam, options);
await this.setCustomObjectIsSoftDeletableCommand.run(passedParam, options);
}
}

View File

@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SetCustomObjectIsSoftDeletableCommand } from 'src/database/commands/upgrade-version/0-24/0-24-set-custom-object-is-soft-deletable.command';
import { SetMessageDirectionCommand } from 'src/database/commands/upgrade-version/0-24/0-24-set-message-direction.command';
import { UpgradeTo0_24Command } from 'src/database/commands/upgrade-version/0-24/0-24-upgrade-version.command';
import { SetCustomObjectIsSoftDeletableCommand } from 'src/database/commands/upgrade-version/0-30/0-30-set-custom-object-is-soft-deletable.command';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';

View File

@@ -0,0 +1,338 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { QueryRunner, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { ViewService } from 'src/modules/view/services/view.service';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
@Command({
name: 'upgrade-0.30:migrate-email-fields-to-emails',
description: 'Migrating fields of deprecated type EMAIL to type EMAILS',
})
export class MigrateEmailFieldsToEmailsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly fieldMetadataService: FieldMetadataService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly typeORMService: TypeORMService,
private readonly dataSourceService: DataSourceService,
private readonly viewService: ViewService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to migrate email type fields to emails type',
);
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceId(
workspaceId,
);
if (!dataSourceMetadata) {
throw new Error(
`Could not find dataSourceMetadata for workspace ${workspaceId}`,
);
}
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
if (!workspaceDataSource) {
throw new Error(
`Could not connect to dataSource for workspace ${workspaceId}`,
);
}
const workspaceQueryRunner = workspaceDataSource.createQueryRunner();
await workspaceQueryRunner.connect();
const customFieldsWithEmailType =
await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.EMAIL,
isCustom: true,
},
});
await this.migratePersonEmailFieldToEmailsField(
workspaceId,
workspaceQueryRunner,
dataSourceMetadata,
);
for (const customFieldWithEmailType of customFieldsWithEmailType) {
const objectMetadata = await this.objectMetadataRepository.findOne({
where: { id: customFieldWithEmailType.objectMetadataId },
});
if (!objectMetadata) {
throw new Error(
`Could not find objectMetadata for field ${customFieldWithEmailType.name}`,
);
}
this.logger.log(
`Attempting to migrate custom field ${customFieldWithEmailType.name} on ${objectMetadata.nameSingular}.`,
);
const fieldName = customFieldWithEmailType.name;
const { id: _id, ...fieldWithEmailTypeWithoutId } =
customFieldWithEmailType;
const emailDefaultValue = fieldWithEmailTypeWithoutId.defaultValue;
const defaultValueForEmailsField = {
primaryEmail: emailDefaultValue,
additionalEmails: null,
};
try {
const tmpNewEmailsField = await this.fieldMetadataService.createOne(
{
...fieldWithEmailTypeWithoutId,
type: FieldMetadataType.EMAILS,
defaultValue: defaultValueForEmailsField,
name: `${fieldName}Tmp`,
} satisfies CreateFieldInput,
);
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
// Migrate data from email to emails.primaryEmail
await this.migrateDataWithinTable({
sourceColumnName: `${customFieldWithEmailType.name}`,
targetColumnName: `${tmpNewEmailsField.name}PrimaryEmail`,
tableName,
workspaceQueryRunner,
dataSourceMetadata,
});
// Duplicate email field's views behaviour for new emails field
await this.viewService.removeFieldFromViews({
workspaceId: workspaceId,
fieldId: tmpNewEmailsField.id,
});
const viewFieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewFieldWorkspaceEntity>(
workspaceId,
'viewField',
);
const viewFieldsWithDeprecatedField =
await viewFieldRepository.find({
where: {
fieldMetadataId: customFieldWithEmailType.id,
isVisible: true,
},
});
await this.viewService.addFieldToViews({
workspaceId: workspaceId,
fieldId: tmpNewEmailsField.id,
viewsIds: viewFieldsWithDeprecatedField
.filter((viewField) => viewField.viewId !== null)
.map((viewField) => viewField.viewId as string),
positions: viewFieldsWithDeprecatedField.reduce(
(acc, viewField) => {
if (!viewField.viewId) {
return acc;
}
acc[viewField.viewId] = viewField.position;
return acc;
},
[],
),
});
// Delete email field
await this.fieldMetadataService.deleteOneField(
{ id: customFieldWithEmailType.id },
workspaceId,
);
// Rename temporary emails field
await this.fieldMetadataService.updateOne(tmpNewEmailsField.id, {
id: tmpNewEmailsField.id,
workspaceId: tmpNewEmailsField.workspaceId,
name: `${fieldName}`,
isCustom: false,
});
this.logger.log(
`Migration of ${customFieldWithEmailType.name} on ${objectMetadata.nameSingular} done!`,
);
} catch (error) {
this.logger.log(
`Failed to migrate field ${customFieldWithEmailType.name} on ${objectMetadata.nameSingular}, rolling back.`,
);
// Re-create initial field if it was deleted
const initialField =
await this.fieldMetadataService.findOneWithinWorkspace(
workspaceId,
{
where: {
name: `${customFieldWithEmailType.name}`,
objectMetadataId: customFieldWithEmailType.objectMetadataId,
},
},
);
const tmpNewEmailsField =
await this.fieldMetadataService.findOneWithinWorkspace(
workspaceId,
{
where: {
name: `${customFieldWithEmailType.name}Tmp`,
objectMetadataId: customFieldWithEmailType.objectMetadataId,
},
},
);
if (!initialField) {
this.logger.log(
`Re-creating initial Email field ${customFieldWithEmailType.name} but of type emails`, // Cannot create email fields anymore
);
const restoredField = await this.fieldMetadataService.createOne({
...customFieldWithEmailType,
defaultValue: defaultValueForEmailsField,
type: FieldMetadataType.EMAILS,
});
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
if (tmpNewEmailsField) {
this.logger.log(
`Restoring data in field ${customFieldWithEmailType.name}`,
);
await this.migrateDataWithinTable({
sourceColumnName: `${tmpNewEmailsField.name}PrimaryEmail`,
targetColumnName: `${restoredField.name}PrimaryEmail`,
tableName,
workspaceQueryRunner,
dataSourceMetadata,
});
} else {
this.logger.log(
`Failed to restore data in link field ${customFieldWithEmailType.name}`,
);
}
}
if (tmpNewEmailsField) {
await this.fieldMetadataService.deleteOneField(
{ id: tmpNewEmailsField.id },
workspaceId,
);
}
} finally {
await workspaceQueryRunner.release();
}
}
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
}
this.logger.log(chalk.green(`Command completed!`));
}
}
private async migratePersonEmailFieldToEmailsField(
workspaceId: string,
workspaceQueryRunner: any,
dataSourceMetadata: any,
) {
this.logger.log(`Migrating person email field of type EMAIL to EMAILS`);
await this.migrateDataWithinTable({
sourceColumnName: 'email',
targetColumnName: 'emailsPrimaryEmail',
tableName: 'person',
workspaceQueryRunner,
dataSourceMetadata,
});
const personEmailFieldMetadata = await this.fieldMetadataRepository.findOne(
{
where: {
workspaceId,
standardId: PERSON_STANDARD_FIELD_IDS.email,
},
},
);
if (personEmailFieldMetadata) {
await this.fieldMetadataService.deleteOneField(
{
id: personEmailFieldMetadata.id,
},
workspaceId,
);
}
}
private async migrateDataWithinTable({
sourceColumnName,
targetColumnName,
tableName,
workspaceQueryRunner,
dataSourceMetadata,
}: {
sourceColumnName: string;
targetColumnName: string;
tableName: string;
workspaceQueryRunner: QueryRunner;
dataSourceMetadata: DataSourceEntity;
}) {
await workspaceQueryRunner.query(
`UPDATE "${dataSourceMetadata.schema}"."${tableName}" SET "${targetColumnName}" = "${sourceColumnName}"`,
);
}
}

View File

@@ -14,7 +14,7 @@ type SetCustomObjectIsSoftDeletableCommandOptions =
ActiveWorkspacesCommandOptions;
@Command({
name: 'upgrade-0.24:set-custom-object-is-soft-deletable',
name: 'upgrade-0.30:set-custom-object-is-soft-deletable',
description: 'Set custom object is soft deletable',
})
export class SetCustomObjectIsSoftDeletableCommand extends ActiveWorkspacesCommandRunner {

View File

@@ -0,0 +1,45 @@
import { Command, CommandRunner, Option } from 'nest-commander';
import { MigrateEmailFieldsToEmailsCommand } from 'src/database/commands/upgrade-version/0-30/0-30-migrate-email-fields-to-emails.command';
import { SetCustomObjectIsSoftDeletableCommand } from 'src/database/commands/upgrade-version/0-30/0-30-set-custom-object-is-soft-deletable.command';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
interface UpdateTo0_30CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.30',
description: 'Upgrade to 0.30',
})
export class UpgradeTo0_30Command extends CommandRunner {
constructor(
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly migrateEmailFieldsToEmails: MigrateEmailFieldsToEmailsCommand,
private readonly setCustomObjectIsSoftDeletableCommand: SetCustomObjectIsSoftDeletableCommand,
) {
super();
}
@Option({
flags: '-w, --workspace-id [workspace_id]',
description:
'workspace id. Command runs on all active workspaces if not provided',
required: false,
})
parseWorkspaceId(value: string): string {
return value;
}
async run(
passedParam: string[],
options: UpdateTo0_30CommandOptions,
): Promise<void> {
await this.syncWorkspaceMetadataCommand.run(passedParam, {
...options,
force: true,
});
await this.setCustomObjectIsSoftDeletableCommand.run(passedParam, options);
await this.migrateEmailFieldsToEmails.run(passedParam, options);
}
}

View File

@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MigrateEmailFieldsToEmailsCommand } from 'src/database/commands/upgrade-version/0-30/0-30-migrate-email-fields-to-emails.command';
import { SetCustomObjectIsSoftDeletableCommand } from 'src/database/commands/upgrade-version/0-30/0-30-set-custom-object-is-soft-deletable.command';
import { UpgradeTo0_30Command } from 'src/database/commands/upgrade-version/0-30/0-30-upgrade-version.command';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
import { ViewModule } from 'src/modules/view/view.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
WorkspaceSyncMetadataCommandsModule,
DataSourceModule,
WorkspaceMetadataVersionModule,
FieldMetadataModule,
TypeOrmModule.forFeature(
[FieldMetadataEntity, ObjectMetadataEntity],
'metadata',
),
TypeORMModule,
ViewModule,
],
providers: [
UpgradeTo0_30Command,
MigrateEmailFieldsToEmailsCommand,
SetCustomObjectIsSoftDeletableCommand,
],
})
export class UpgradeTo0_30CommandModule {}

View File

@@ -37,7 +37,7 @@ export const seedPeople = async (
'phone',
'city',
'companyId',
'email',
'emailsPrimaryEmail',
'position',
'whatsapp',
'createdBySource',
@@ -53,7 +53,7 @@ export const seedPeople = async (
phone: '+33789012345',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.LINKEDIN,
email: 'christoph.calisto@linkedin.com',
emailsPrimaryEmail: 'christoph.calisto@linkedin.com',
position: 1,
whatsapp: '+33789012345',
createdBySource: 'MANUAL',
@@ -67,7 +67,7 @@ export const seedPeople = async (
phone: '+33780123456',
city: 'Los Angeles',
companyId: DEV_SEED_COMPANY_IDS.LINKEDIN,
email: 'sylvie.palmer@linkedin.com',
emailsPrimaryEmail: 'sylvie.palmer@linkedin.com',
position: 2,
whatsapp: '+33780123456',
createdBySource: 'MANUAL',
@@ -81,7 +81,7 @@ export const seedPeople = async (
phone: '+33789012345',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.QONTO,
email: 'christopher.gonzalez@qonto.com',
emailsPrimaryEmail: 'christopher.gonzalez@qonto.com',
position: 3,
whatsapp: '+33789012345',
createdBySource: 'MANUAL',
@@ -95,7 +95,7 @@ export const seedPeople = async (
phone: '+33780123456',
city: 'Los Angeles',
companyId: DEV_SEED_COMPANY_IDS.QONTO,
email: 'ashley.parker@qonto.com',
emailsPrimaryEmail: 'ashley.parker@qonto.com',
position: 4,
whatsapp: '+33780123456',
createdBySource: 'MANUAL',
@@ -109,7 +109,7 @@ export const seedPeople = async (
phone: '+33781234567',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.MICROSOFT,
email: 'nicholas.wright@microsoft.com',
emailsPrimaryEmail: 'nicholas.wright@microsoft.com',
position: 5,
whatsapp: '+33781234567',
createdBySource: 'MANUAL',
@@ -123,7 +123,7 @@ export const seedPeople = async (
phone: '+33782345678',
city: 'New York',
companyId: DEV_SEED_COMPANY_IDS.MICROSOFT,
email: 'isabella.scott@microsoft.com',
emailsPrimaryEmail: 'isabella.scott@microsoft.com',
position: 6,
whatsapp: '+33782345678',
createdBySource: 'MANUAL',
@@ -137,7 +137,7 @@ export const seedPeople = async (
phone: '+33783456789',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.MICROSOFT,
email: 'matthew.green@microsoft.com',
emailsPrimaryEmail: 'matthew.green@microsoft.com',
position: 7,
whatsapp: '+33783456789',
createdBySource: 'MANUAL',
@@ -151,7 +151,7 @@ export const seedPeople = async (
phone: '+33784567890',
city: 'New York',
companyId: DEV_SEED_COMPANY_IDS.AIRBNB,
email: 'elizabeth.baker@airbnb.com',
emailsPrimaryEmail: 'elizabeth.baker@airbnb.com',
position: 8,
whatsapp: '+33784567890',
createdBySource: 'MANUAL',
@@ -165,7 +165,7 @@ export const seedPeople = async (
phone: '+33785678901',
city: 'San Francisco',
companyId: DEV_SEED_COMPANY_IDS.AIRBNB,
email: 'christopher.nelson@airbnb.com',
emailsPrimaryEmail: 'christopher.nelson@airbnb.com',
position: 9,
whatsapp: '+33785678901',
createdBySource: 'MANUAL',
@@ -179,7 +179,7 @@ export const seedPeople = async (
phone: '+33786789012',
city: 'New York',
companyId: DEV_SEED_COMPANY_IDS.AIRBNB,
email: 'avery.carter@airbnb.com',
emailsPrimaryEmail: 'avery.carter@airbnb.com',
position: 10,
whatsapp: '+33786789012',
createdBySource: 'MANUAL',
@@ -193,7 +193,7 @@ export const seedPeople = async (
phone: '+33787890123',
city: 'Los Angeles',
companyId: DEV_SEED_COMPANY_IDS.GOOGLE,
email: 'ethan.mitchell@google.com',
emailsPrimaryEmail: 'ethan.mitchell@google.com',
position: 11,
whatsapp: '+33787890123',
createdBySource: 'MANUAL',
@@ -207,7 +207,7 @@ export const seedPeople = async (
phone: '+33788901234',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.GOOGLE,
email: 'madison.perez@google.com',
emailsPrimaryEmail: 'madison.perez@google.com',
position: 12,
whatsapp: '+33788901234',
createdBySource: 'MANUAL',
@@ -221,7 +221,7 @@ export const seedPeople = async (
phone: '+33788901234',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.GOOGLE,
email: 'bertrand.voulzy@google.com',
emailsPrimaryEmail: 'bertrand.voulzy@google.com',
position: 13,
whatsapp: '+33788901234',
createdBySource: 'MANUAL',
@@ -235,7 +235,7 @@ export const seedPeople = async (
phone: '+33788901234',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.GOOGLE,
email: 'louis.duss@google.com',
emailsPrimaryEmail: 'louis.duss@google.com',
position: 14,
whatsapp: '+33788901234',
createdBySource: 'MANUAL',
@@ -249,7 +249,7 @@ export const seedPeople = async (
phone: '+33788901235',
city: 'Seattle',
companyId: DEV_SEED_COMPANY_IDS.GOOGLE,
email: 'lorie.vladim@google.com',
emailsPrimaryEmail: 'lorie.vladim@google.com',
position: 15,
whatsapp: '+33788901235',
createdBySource: 'MANUAL',

View File

@@ -22,5 +22,5 @@ export const emailsCompositeType: CompositeType = {
export type EmailsMetadata = {
primaryEmail: string;
additionalEmails: string[] | null;
additionalEmails: object | null;
};

View File

@@ -183,7 +183,7 @@ export class FieldMetadataDefaultValueEmails {
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalEmails: string[] | null;
additionalEmails: object | null;
}
export class FieldMetadataDefaultValuePhones {

View File

@@ -14,7 +14,7 @@ export const personPrefillDemoData = async (
const people = peopleDemo.map((person, index) => ({
nameFirstName: person.firstName,
nameLastName: person.lastName,
email: person.email,
emailsPrimaryEmail: person.email,
linkedinLinkPrimaryLinkUrl: person.linkedinUrl,
jobTitle: person.jobTitle,
city: person.city,
@@ -32,7 +32,7 @@ export const personPrefillDemoData = async (
.into(`${schemaName}.person`, [
'nameFirstName',
'nameLastName',
'email',
'emailsPrimaryEmail',
'linkedinLinkPrimaryLinkUrl',
'jobTitle',
'city',

View File

@@ -12,7 +12,7 @@ export const personPrefillData = async (
'nameFirstName',
'nameLastName',
'city',
'email',
'emailsPrimaryEmail',
'avatarUrl',
'position',
'createdBySource',
@@ -25,7 +25,7 @@ export const personPrefillData = async (
nameFirstName: 'Brian',
nameLastName: 'Chesky',
city: 'San Francisco',
email: 'chesky@airbnb.com',
emailsPrimaryEmail: 'chesky@airbnb.com',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-3.png',
position: 1,
@@ -37,7 +37,7 @@ export const personPrefillData = async (
nameFirstName: 'Alexandre',
nameLastName: 'Prot',
city: 'Paris',
email: 'prot@qonto.com',
emailsPrimaryEmail: 'prot@qonto.com',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-89.png',
position: 2,
@@ -49,7 +49,7 @@ export const personPrefillData = async (
nameFirstName: 'Patrick',
nameLastName: 'Collison',
city: 'San Francisco',
email: 'collison@stripe.com',
emailsPrimaryEmail: 'collison@stripe.com',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-47.png',
position: 3,
@@ -61,7 +61,7 @@ export const personPrefillData = async (
nameFirstName: 'Dylan',
nameLastName: 'Field',
city: 'San Francisco',
email: 'field@figma.com',
emailsPrimaryEmail: 'field@figma.com',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-40.png',
position: 4,
@@ -73,7 +73,7 @@ export const personPrefillData = async (
nameFirstName: 'Ivan',
nameLastName: 'Zhao',
city: 'San Francisco',
email: 'zhao@notion.com',
emailsPrimaryEmail: 'zhao@notion.com',
avatarUrl:
'https://twentyhq.github.io/placeholder-images/people/image-68.png',
position: 5,

View File

@@ -30,7 +30,7 @@ export const peopleAllView = async (
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.person].fields[
PERSON_STANDARD_FIELD_IDS.email
PERSON_STANDARD_FIELD_IDS.emails
],
position: 1,
isVisible: true,

View File

@@ -305,6 +305,7 @@ export const OPPORTUNITY_STANDARD_FIELD_IDS = {
export const PERSON_STANDARD_FIELD_IDS = {
name: '20202020-3875-44d5-8c33-a6239011cab8',
email: '20202020-a740-42bb-8849-8980fb3f12e1',
emails: '20202020-3c51-43fa-8b6e-af39e29368ab',
linkedinLink: '20202020-f1af-48f7-893b-2007a73dd508',
xLink: '20202020-8fc2-487c-b84a-55a99b145cfd',
jobTitle: '20202020-b0d0-415a-bef9-640a26dacd9b',

View File

@@ -32,7 +32,10 @@ export class CalendarEventParticipantPersonListener {
>,
) {
for (const eventPayload of payload.events) {
if (!eventPayload.properties.after.email) {
if (
eventPayload.properties.after.emails?.primaryEmail === null &&
eventPayload.properties.after.email === null
) {
continue;
}
@@ -41,7 +44,9 @@ export class CalendarEventParticipantPersonListener {
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.email,
email:
eventPayload.properties.after.emails?.primaryEmail ??
eventPayload.properties.after.email, // TODO
personId: eventPayload.recordId,
},
);
@@ -66,7 +71,9 @@ export class CalendarEventParticipantPersonListener {
CalendarEventParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.email,
email:
eventPayload.properties.before.emails?.primaryEmail ??
eventPayload.properties.before.email,
personId: eventPayload.recordId,
},
);
@@ -75,7 +82,9 @@ export class CalendarEventParticipantPersonListener {
CalendarEventParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.email,
email:
eventPayload.properties.after.emails?.primaryEmail ??
eventPayload.properties.after.email,
personId: eventPayload.recordId,
},
);

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
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 { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@@ -17,7 +18,10 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberWorkspaceEntity]),
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
],
providers: [
CreateCompanyService,

View File

@@ -1,16 +1,19 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import chunk from 'lodash.chunk';
import compact from 'lodash.compact';
import { Any, EntityManager, Repository } from 'typeorm';
import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
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 { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { 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';
@@ -35,6 +38,8 @@ export class CreateCompanyAndContactService {
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
@@ -49,6 +54,13 @@ export class CreateCompanyAndContactService {
return [];
}
const emailsFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
workspaceId: workspaceId,
standardId: PERSON_STANDARD_FIELD_IDS.emails,
},
});
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
@@ -77,14 +89,16 @@ export class CreateCompanyAndContactService {
}
const alreadyCreatedContacts = await personRepository.find({
where: {
email: Any(uniqueHandles),
},
where: isDefined(emailsFieldMetadata)
? {
emails: { primaryEmail: Any(uniqueHandles) },
}
: { email: Any(uniqueHandles) },
});
const alreadyCreatedContactEmails: string[] = alreadyCreatedContacts?.map(
({ email }) => email,
);
const alreadyCreatedContactEmails: string[] = isDefined(emailsFieldMetadata)
? alreadyCreatedContacts?.map(({ emails }) => emails?.primaryEmail)
: alreadyCreatedContacts?.map(({ email }) => email);
const filteredContactsToCreate = uniqueContacts.filter(
(participant) =>
@@ -129,8 +143,11 @@ export class CreateCompanyAndContactService {
createdByWorkspaceMember: connectedAccount.accountOwner,
}));
const shouldUseEmailsField = isDefined(emailsFieldMetadata);
return this.createContactService.createPeople(
formattedContactsToCreate,
shouldUseEmailsField,
workspaceId,
transactionManager,
);

View File

@@ -28,6 +28,7 @@ export class CreateContactService {
private formatContacts(
contactsToCreate: ContactToCreate[],
lastPersonPosition: number,
shouldUseEmailsField: boolean,
): DeepPartial<PersonWorkspaceEntity>[] {
return contactsToCreate.map((contact) => {
const id = v4();
@@ -46,7 +47,9 @@ export class CreateContactService {
return {
id,
email: handle,
...(shouldUseEmailsField
? { emails: { primaryEmail: handle, additionalEmails: null } }
: { email: handle }),
name: {
firstName,
lastName,
@@ -64,6 +67,7 @@ export class CreateContactService {
public async createPeople(
contactsToCreate: ContactToCreate[],
shouldUseEmailsField: boolean,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<DeepPartial<PersonWorkspaceEntity>[]> {
@@ -83,6 +87,7 @@ export class CreateContactService {
const formattedContacts = this.formatContacts(
contactsToCreate,
lastPersonPosition,
shouldUseEmailsField,
);
return personRepository.save(

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { MatchParticipantService } from 'src/modules/match-participant/match-participant.service';
@Module({
imports: [],
imports: [TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata')],
providers: [ScopedWorkspaceContextFactory, MatchParticipantService],
exports: [MatchParticipantService],
})

View File

@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Any, EntityManager } from 'typeorm';
import { Any, EntityManager, Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@@ -20,6 +23,8 @@ export class MatchParticipantService<
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly twentyORMManager: TwentyORMManager,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
private async getParticipantRepository(
@@ -55,19 +60,35 @@ export class MatchParticipantService<
...new Set(participants.map((participant) => participant.handle)),
];
const emailsFieldMetadata = await this.fieldMetadataRepository.findOne({
where: {
workspaceId: workspaceId,
standardId: PERSON_STANDARD_FIELD_IDS.emails,
},
});
const personRepository =
await this.twentyORMManager.getRepository<PersonWorkspaceEntity>(
'person',
);
const people = await personRepository.find(
{
where: {
email: Any(uniqueParticipantsHandles),
},
},
transactionManager,
);
const people = emailsFieldMetadata
? await personRepository.find(
{
where: {
emails: Any(uniqueParticipantsHandles),
},
},
transactionManager,
)
: await personRepository.find(
{
where: {
email: Any(uniqueParticipantsHandles),
},
},
transactionManager,
);
const workspaceMemberRepository =
await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>(
@@ -84,7 +105,11 @@ export class MatchParticipantService<
);
for (const handle of uniqueParticipantsHandles) {
const person = people.find((person) => person.email === handle);
const person = people.find((person) =>
emailsFieldMetadata
? person.emails?.primaryEmail === handle
: person.email === handle,
);
const workspaceMember = workspaceMembers.find(
(workspaceMember) => workspaceMember.userEmail === handle,

View File

@@ -32,7 +32,10 @@ export class MessageParticipantPersonListener {
>,
) {
for (const eventPayload of payload.events) {
if (!eventPayload.properties.after.email) {
if (
!eventPayload.properties.after.emails?.primaryEmail &&
!eventPayload.properties.after.email
) {
continue;
}
@@ -40,7 +43,9 @@ export class MessageParticipantPersonListener {
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.email,
email:
eventPayload.properties.after.emails?.primaryEmail ??
eventPayload.properties.after.email,
personId: eventPayload.recordId,
},
);
@@ -58,13 +63,19 @@ export class MessageParticipantPersonListener {
objectRecordUpdateEventChangedProperties(
eventPayload.properties.before,
eventPayload.properties.after,
).includes('email')
).includes('email') ||
objectRecordUpdateEventChangedProperties(
eventPayload.properties.before,
eventPayload.properties.after,
).includes('emails')
) {
await this.messageQueueService.add<MessageParticipantUnmatchParticipantJobData>(
MessageParticipantUnmatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.before.email,
email:
eventPayload.properties.before.emails?.primaryEmail ??
eventPayload.properties.before.email,
personId: eventPayload.recordId,
},
);
@@ -73,7 +84,9 @@ export class MessageParticipantPersonListener {
MessageParticipantMatchParticipantJob.name,
{
workspaceId: payload.workspaceId,
email: eventPayload.properties.after.email,
email:
eventPayload.properties.after.emails?.primaryEmail ??
eventPayload.properties.after.email,
personId: eventPayload.recordId,
},
);

View File

@@ -4,6 +4,7 @@ import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type';
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -14,6 +15,7 @@ import {
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsDeprecated } from 'src/engine/twenty-orm/decorators/workspace-is-deprecated.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-join-column.decorator';
@@ -59,8 +61,18 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: 'Contacts Email',
icon: 'IconMail',
})
@WorkspaceIsDeprecated()
email: string;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.emails,
type: FieldMetadataType.EMAILS,
label: 'Emails',
description: 'Contacts Emails',
icon: 'IconMail',
})
emails: EmailsMetadata;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.linkedinLink,
type: FieldMetadataType.LINKS,