Add command to tag workspace as suspended or as deleted (#9610)

In this PR:
- remove old versions upgrade commands
- add a 0.40 upgrade command to loop over all INACTIVE workspaces and
either: update to SUSPENDED (if workspaceSchema exists), update them to
SUSPENDED + deletedAt (if workspaceSchema does not exist anymore)

Note: why updating the deleted one to SUSPENDED? Because I plan to
remove INACTIVE case in the enum in 0.41

Tests made on production like database:
- dry-mode
- singleWorkspaceId
- 3 cases : suspended, deleted+suspended, deleted+suspended+delete all
data
This commit is contained in:
Charles Bochet
2025-01-14 18:23:42 +01:00
committed by GitHub
parent 87be542185
commit 42ddc09f74
26 changed files with 290 additions and 2087 deletions

View File

@ -7,10 +7,6 @@ import { DataSeedDemoWorkspaceCommand } from 'src/database/commands/data-seed-de
import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-demo-workspace/data-seed-demo-workspace.module';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { UpgradeTo0_32CommandModule } from 'src/database/commands/upgrade-version/0-32/0-32-upgrade-version.module';
import { UpgradeTo0_33CommandModule } from 'src/database/commands/upgrade-version/0-33/0-33-upgrade-version.module';
import { UpgradeTo0_34CommandModule } from 'src/database/commands/upgrade-version/0-34/0-34-upgrade-version.module';
import { UpgradeTo0_35CommandModule } from 'src/database/commands/upgrade-version/0-35/0-35-upgrade-version.module';
import { UpgradeTo0_40CommandModule } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ -52,10 +48,6 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
DataSeedDemoWorkspaceModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
UpgradeTo0_32CommandModule,
UpgradeTo0_33CommandModule,
UpgradeTo0_34CommandModule,
UpgradeTo0_35CommandModule,
UpgradeTo0_40CommandModule,
FeatureFlagModule,
],

View File

@ -28,12 +28,12 @@ export class CommandLogger {
this.logger.error(message, stack, context);
}
warn(message: string, context?: string) {
this.logger.warn(message, context);
warn(message: string, ...optionalParams: [...any, string?]) {
this.logger.warn(message, ...optionalParams);
}
debug(message: string, context?: string) {
this.logger.debug(message, context);
debug(message: string, ...optionalParams: [...any, string?]) {
this.logger.debug(message, ...optionalParams);
}
verbose(message: string, ...optionalParams: [...any, string?]) {

View File

@ -1,127 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@Command({
name: 'upgrade-0.32:backfill-view-groups',
description: 'Backfill view groups',
})
export class BackfillViewGroupsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix backfill view groups');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
);
const viewGroupRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewGroupWorkspaceEntity>(
workspaceId,
'viewGroup',
);
const kanbanViews = await viewRepository.find({
where: {
type: 'kanban',
},
});
const kanbanFieldMetadataIds = kanbanViews.map(
(view) => view.kanbanFieldMetadataId,
);
const kanbanFieldMetadataItems =
await this.fieldMetadataRepository.find({
where: {
id: In(kanbanFieldMetadataIds),
},
});
for (const kanbanView of kanbanViews) {
const kanbanFieldMetadataItem = kanbanFieldMetadataItems.find(
(item) => item.id === kanbanView.kanbanFieldMetadataId,
);
if (!kanbanFieldMetadataItem) {
this.logger.log(
chalk.red(
`Kanban field metadata with id ${kanbanView.kanbanFieldMetadataId} not found`,
),
);
continue;
}
for (const option of kanbanFieldMetadataItem.options) {
const viewGroup = await viewGroupRepository.findOne({
where: {
fieldMetadataId: kanbanFieldMetadataItem.id,
fieldValue: option.value,
viewId: kanbanView.id,
},
});
if (viewGroup) {
this.logger.log(
chalk.red(`View group with id ${option.value} already exists`),
);
continue;
}
await viewGroupRepository.save({
fieldMetadataId: kanbanFieldMetadataItem.id,
fieldValue: option.value,
isVisible: true,
viewId: kanbanView.id,
position: option.position,
});
}
}
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}
}

View File

@ -1,85 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import chalk from 'chalk';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade-0.32:copy-webhook-operation-into-operations',
description:
'Read, transform and copy webhook from deprecated column operation into newly created column operations',
})
export class CopyWebhookOperationIntoOperationsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParams: string[],
options: BaseCommandOptions,
activeWorkspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to copy operation to operations');
for (const workspaceId of activeWorkspaceIds) {
try {
this.logger.log(`Running command for workspace ${workspaceId}`);
const webhookRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'webhook',
);
const webhooks = await webhookRepository.find();
for (const webhook of webhooks) {
if ('operation' in webhook) {
let newOpe = webhook.operation;
newOpe = newOpe.replace(/\bcreate\b(?=\.|$)/g, 'created');
newOpe = newOpe.replace(/\bupdate\b(?=\.|$)/g, 'updated');
newOpe = newOpe.replace(/\bdelete\b(?=\.|$)/g, 'deleted');
newOpe = newOpe.replace(/\bdestroy\b(?=\.|$)/g, 'destroyed');
const [firstWebhookPart, lastWebhookPart] = newOpe.split('.');
if (
['created', 'updated', 'deleted', 'destroyed'].includes(
firstWebhookPart,
)
) {
newOpe = `${lastWebhookPart}.${firstWebhookPart}`;
}
await webhookRepository.update(webhook.id, {
operation: newOpe,
operations: [newOpe],
});
this.logger.log(
chalk.yellow(
`Handled webhook operation updates for ${webhook.id}`,
),
);
}
}
} catch (e) {
this.logger.log(
chalk.red(
`Error when running command on workspace ${workspaceId}: ${e}`,
),
);
}
}
}
}

View File

@ -1,112 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared';
import { Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { SearchService } from 'src/engine/metadata-modules/search/search.service';
import { SEARCH_FIELDS_FOR_CUSTOM_OBJECT } from 'src/engine/twenty-orm/custom.workspace-entity';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import {
COMPANY_STANDARD_FIELD_IDS,
CUSTOM_OBJECT_STANDARD_FIELD_IDS,
OPPORTUNITY_STANDARD_FIELD_IDS,
PERSON_STANDARD_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_COMPANY } from 'src/modules/company/standard-objects/company.workspace-entity';
import { SEARCH_FIELDS_FOR_OPPORTUNITY } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { SEARCH_FIELDS_FOR_PERSON } from 'src/modules/person/standard-objects/person.workspace-entity';
@Command({
name: 'fix-0.32:simplify-search-vector-expression',
description: 'Replace searchVector with simpler expression',
})
export class SimplifySearchVectorExpressionCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly searchService: SearchService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix migration');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
const searchVectorFields = await this.fieldMetadataRepository.findBy({
workspaceId: workspaceId,
type: FieldMetadataType.TS_VECTOR,
});
for (const searchVectorField of searchVectorFields) {
let fieldsUsedForSearch: FieldTypeAndNameMetadata[] = [];
switch (searchVectorField.standardId) {
case CUSTOM_OBJECT_STANDARD_FIELD_IDS.searchVector: {
fieldsUsedForSearch = SEARCH_FIELDS_FOR_CUSTOM_OBJECT;
break;
}
case PERSON_STANDARD_FIELD_IDS.searchVector: {
fieldsUsedForSearch = SEARCH_FIELDS_FOR_PERSON;
break;
}
case COMPANY_STANDARD_FIELD_IDS.searchVector: {
fieldsUsedForSearch = SEARCH_FIELDS_FOR_COMPANY;
break;
}
case OPPORTUNITY_STANDARD_FIELD_IDS.searchVector: {
fieldsUsedForSearch = SEARCH_FIELDS_FOR_OPPORTUNITY;
break;
}
}
if (fieldsUsedForSearch.length === 0) {
continue;
}
await this.searchService.updateSearchVector(
searchVectorField.objectMetadataId,
fieldsUsedForSearch,
workspaceId,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
}
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}
}

View File

@ -1,65 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BackfillViewGroupsCommand } from 'src/database/commands/upgrade-version/0-32/0-32-backfill-view-groups.command';
import { CopyWebhookOperationIntoOperationsCommand } from 'src/database/commands/upgrade-version/0-32/0-32-copy-webhook-operation-into-operations-command';
import { SimplifySearchVectorExpressionCommand } from 'src/database/commands/upgrade-version/0-32/0-32-simplify-search-vector-expression';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
interface UpdateTo0_32CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.32',
description: 'Upgrade to 0.32',
})
export class UpgradeTo0_32Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly simplifySearchVectorExpressionCommand: SimplifySearchVectorExpressionCommand,
private readonly copyWebhookOperationIntoOperationsCommand: CopyWebhookOperationIntoOperationsCommand,
private readonly backfillViewGroupsCommand: BackfillViewGroupsCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_32CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
await this.simplifySearchVectorExpressionCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.copyWebhookOperationIntoOperationsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.backfillViewGroupsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -1,34 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CopyWebhookOperationIntoOperationsCommand } from 'src/database/commands/upgrade-version/0-32/0-32-copy-webhook-operation-into-operations-command';
import { SimplifySearchVectorExpressionCommand } from 'src/database/commands/upgrade-version/0-32/0-32-simplify-search-vector-expression';
import { UpgradeTo0_32Command } from 'src/database/commands/upgrade-version/0-32/0-32-upgrade-version.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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 { SearchModule } from 'src/engine/metadata-modules/search/search.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
import { BackfillViewGroupsCommand } from './0-32-backfill-view-groups.command';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
WorkspaceSyncMetadataCommandsModule,
SearchModule,
WorkspaceMigrationRunnerModule,
],
providers: [
UpgradeTo0_32Command,
BackfillViewGroupsCommand,
CopyWebhookOperationIntoOperationsCommand,
SimplifySearchVectorExpressionCommand,
],
})
export class UpgradeTo0_32CommandModule {}

View File

@ -1,95 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { IsNull, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
interface DeleteViewFieldsWithoutViewsCommandOptions
extends ActiveWorkspacesCommandOptions {}
@Command({
name: 'upgrade-0.33:delete-view-fields-without-views',
description: 'Delete ViewFields that do not have a View',
})
export class DeleteViewFieldsWithoutViewsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: DeleteViewFieldsWithoutViewsCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to delete ViewFields that do not have a View',
);
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
await this.deleteViewFieldsWithoutViewsForWorkspace(
workspaceId,
options,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async deleteViewFieldsWithoutViewsForWorkspace(
workspaceId: string,
options: DeleteViewFieldsWithoutViewsCommandOptions,
): Promise<void> {
const viewFieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewField',
false,
);
const viewFieldsWithoutViews = await viewFieldRepository.find({
where: {
viewId: IsNull(),
},
});
const viewFieldIds = viewFieldsWithoutViews.map((vf) => vf.id);
if (!options.dryRun && viewFieldIds.length > 0) {
await viewFieldRepository.delete(viewFieldIds);
}
if (options.verbose) {
this.logger.log(
chalk.yellow(
`Deleted ${viewFieldsWithoutViews.length} ViewFields that do not have a View`,
),
);
}
}
}

View File

@ -1,320 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, Option } from 'nest-commander';
import { IsNull, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
interface EnforceUniqueConstraintsCommandOptions
extends ActiveWorkspacesCommandOptions {
person?: boolean;
company?: boolean;
viewField?: boolean;
viewSort?: boolean;
}
@Command({
name: 'upgrade-0.33:enforce-unique-constraints',
description:
'Enforce unique constraints on company domainName, person emailsPrimaryEmail, ViewField, and ViewSort',
})
export class EnforceUniqueConstraintsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
@Option({
flags: '--person',
description: 'Enforce unique constraints on person emailsPrimaryEmail',
})
parsePerson() {
return true;
}
@Option({
flags: '--company',
description: 'Enforce unique constraints on company domainName',
})
parseCompany() {
return true;
}
@Option({
flags: '--view-field',
description: 'Enforce unique constraints on ViewField',
})
parseViewField() {
return true;
}
@Option({
flags: '--view-sort',
description: 'Enforce unique constraints on ViewSort',
})
parseViewSort() {
return true;
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: EnforceUniqueConstraintsCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to enforce unique constraints');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
await this.enforceUniqueConstraintsForWorkspace(workspaceId, options);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async enforceUniqueConstraintsForWorkspace(
workspaceId: string,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
if (options.person) {
await this.enforceUniquePersonEmail(workspaceId, options);
}
if (options.company) {
await this.enforceUniqueCompanyDomainName(workspaceId, options);
}
if (options.viewField) {
await this.enforceUniqueViewField(workspaceId, options);
}
if (options.viewSort) {
await this.enforceUniqueViewSort(workspaceId, options);
}
}
private async enforceUniqueCompanyDomainName(
workspaceId: string,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
const companyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'company',
false,
);
const duplicates = await companyRepository
.createQueryBuilder('company')
.select('company.domainNamePrimaryLinkUrl')
.addSelect('COUNT(*)', 'count')
.where('company.deletedAt IS NULL')
.where('company.domainNamePrimaryLinkUrl IS NOT NULL')
.where("company.domainNamePrimaryLinkUrl != ''")
.groupBy('company.domainNamePrimaryLinkUrl')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { company_domainNamePrimaryLinkUrl } = duplicate;
const companies = await companyRepository.find({
where: {
domainName: {
primaryLinkUrl: company_domainNamePrimaryLinkUrl,
},
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < companies.length; i++) {
const newdomainNamePrimaryLinkUrl = `${company_domainNamePrimaryLinkUrl}${i}`;
if (!options.dryRun) {
await companyRepository.update(companies[i].id, {
domainNamePrimaryLinkUrl: newdomainNamePrimaryLinkUrl,
});
}
if (options.verbose) {
this.logger.log(
chalk.yellow(
`Updated company ${companies[i].id} domainName from ${company_domainNamePrimaryLinkUrl} to ${newdomainNamePrimaryLinkUrl}`,
),
);
}
}
}
}
private async enforceUniquePersonEmail(
workspaceId: string,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'person',
false,
);
const duplicates = await personRepository
.createQueryBuilder('person')
.select('person.emailsPrimaryEmail')
.addSelect('COUNT(*)', 'count')
.where('person.deletedAt IS NULL')
.where('person.emailsPrimaryEmail IS NOT NULL')
.where("person.emailsPrimaryEmail != ''")
.groupBy('person.emailsPrimaryEmail')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { person_emailsPrimaryEmail } = duplicate;
const persons = await personRepository.find({
where: {
emails: {
primaryEmail: person_emailsPrimaryEmail,
},
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < persons.length; i++) {
const newEmail = person_emailsPrimaryEmail?.includes('@')
? `${person_emailsPrimaryEmail.split('@')[0]}+${i}@${person_emailsPrimaryEmail.split('@')[1]}`
: `${person_emailsPrimaryEmail}+${i}`;
if (!options.dryRun) {
await personRepository.update(persons[i].id, {
emailsPrimaryEmail: newEmail,
});
}
if (options.verbose) {
this.logger.log(
chalk.yellow(
`Updated person ${persons[i].id} emailsPrimaryEmail from ${person_emailsPrimaryEmail} to ${newEmail}`,
),
);
}
}
}
}
private async enforceUniqueViewField(
workspaceId: string,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
const viewFieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewField',
false,
);
const duplicates = await viewFieldRepository
.createQueryBuilder('viewField')
.select(['viewField.fieldMetadataId', 'viewField.viewId'])
.addSelect('COUNT(*)', 'count')
.where('viewField.deletedAt IS NULL')
.groupBy('viewField.fieldMetadataId, viewField.viewId')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const {
viewField_fieldMetadataId: fieldMetadataId,
viewField_viewId: viewId,
} = duplicate;
const viewFields = await viewFieldRepository.find({
where: {
fieldMetadataId,
viewId,
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < viewFields.length; i++) {
if (!options.dryRun) {
await viewFieldRepository.softDelete(viewFields[i].id);
}
if (options.verbose) {
this.logger.log(
chalk.yellow(
`Soft deleted duplicate ViewField ${viewFields[i].id} for fieldMetadataId ${fieldMetadataId} and viewId ${viewId}`,
),
);
}
}
}
}
private async enforceUniqueViewSort(
workspaceId: string,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
const viewSortRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewSort',
false,
);
const duplicates = await viewSortRepository
.createQueryBuilder('viewSort')
.select(['viewSort.fieldMetadataId', 'viewSort.viewId'])
.addSelect('COUNT(*)', 'count')
.where('viewSort.deletedAt IS NULL')
.groupBy('viewSort.fieldMetadataId, viewSort.viewId')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const {
viewSort_fieldMetadataId: fieldMetadataId,
viewSort_viewId: viewId,
} = duplicate;
const viewSorts = await viewSortRepository.find({
where: {
fieldMetadataId,
viewId,
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < viewSorts.length; i++) {
if (!options.dryRun) {
await viewSortRepository.softDelete(viewSorts[i].id);
}
if (options.verbose) {
this.logger.log(
chalk.yellow(
`Soft deleted duplicate ViewSort ${viewSorts[i].id} for fieldMetadataId ${fieldMetadataId} and viewId ${viewId}`,
),
);
}
}
}
}
}

View File

@ -1,108 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { IsNull, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { CUSTOM_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
interface SetMissingLabelIdentifierToCustomObjectsCommandOptions
extends ActiveWorkspacesCommandOptions {}
@Command({
name: 'upgrade-0.33:set-missing-label-identifier-to-custom-objects',
description: 'Set missing labelIdentifier to custom objects',
})
export class SetMissingLabelIdentifierToCustomObjectsCommand 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 twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: SetMissingLabelIdentifierToCustomObjectsCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to set missing labelIdentifier to custom objects',
);
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
await this.setMissingLabelIdentifierToCustomObjectsForWorkspace(
workspaceId,
options,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async setMissingLabelIdentifierToCustomObjectsForWorkspace(
workspaceId: string,
options: SetMissingLabelIdentifierToCustomObjectsCommandOptions,
): Promise<void> {
const customObjects = await this.objectMetadataRepository.find({
where: {
workspaceId,
labelIdentifierFieldMetadataId: IsNull(),
isCustom: true,
},
});
for (const customObject of customObjects) {
const labelIdentifierFieldMetadata =
await this.fieldMetadataRepository.findOne({
where: {
workspaceId,
objectMetadataId: customObject.id,
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
},
});
if (labelIdentifierFieldMetadata && !options.dryRun) {
await this.objectMetadataRepository.update(customObject.id, {
labelIdentifierFieldMetadataId: labelIdentifierFieldMetadata.id,
});
}
if (options.verbose) {
this.logger.log(
chalk.yellow(
`Set labelIdentifierFieldMetadataId for custom object ${customObject.nameSingular}`,
),
);
}
}
}
}

View File

@ -1,107 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared';
import { Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { SearchService } from 'src/engine/metadata-modules/search/search.service';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import {
NOTE_STANDARD_FIELD_IDS,
TASK_STANDARD_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { SEARCH_FIELDS_FOR_NOTES } from 'src/modules/note/standard-objects/note.workspace-entity';
import { SEARCH_FIELDS_FOR_TASK } from 'src/modules/task/standard-objects/task.workspace-entity';
@Command({
name: 'fix-0.33:update-rich-text-expression',
description: 'Update rich text (note and task) search vector expressions',
})
export class UpdateRichTextSearchVectorCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly searchService: SearchService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix migration');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
const searchVectorFields = await this.fieldMetadataRepository.findBy({
workspaceId: workspaceId,
type: FieldMetadataType.TS_VECTOR,
});
for (const searchVectorField of searchVectorFields) {
let fieldsUsedForSearch: FieldTypeAndNameMetadata[] = [];
let objectNameForLog = '';
switch (searchVectorField.standardId) {
case NOTE_STANDARD_FIELD_IDS.searchVector: {
fieldsUsedForSearch = SEARCH_FIELDS_FOR_NOTES;
objectNameForLog = 'Note';
break;
}
case TASK_STANDARD_FIELD_IDS.searchVector: {
fieldsUsedForSearch = SEARCH_FIELDS_FOR_TASK;
objectNameForLog = 'Task';
break;
}
}
if (fieldsUsedForSearch.length === 0) {
continue;
}
this.logger.log(
`Updating searchVector for ${searchVectorField.objectMetadataId} (${objectNameForLog})...`,
);
await this.searchService.updateSearchVector(
searchVectorField.objectMetadataId,
fieldsUsedForSearch,
workspaceId,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
}
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}
}

View File

@ -1,75 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { DeleteViewFieldsWithoutViewsCommand } from 'src/database/commands/upgrade-version/0-33/0-33-delete-view-fields-without-views.command';
import { EnforceUniqueConstraintsCommand } from 'src/database/commands/upgrade-version/0-33/0-33-enforce-unique-constraints.command';
import { SetMissingLabelIdentifierToCustomObjectsCommand } from 'src/database/commands/upgrade-version/0-33/0-33-set-missing-label-identifier-to-custom-objects.command';
import { UpdateRichTextSearchVectorCommand } from 'src/database/commands/upgrade-version/0-33/0-33-update-rich-text-search-vector-expression';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
interface UpdateTo0_33CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.33',
description: 'Upgrade to 0.33',
})
export class UpgradeTo0_33Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly updateRichTextSearchVectorCommand: UpdateRichTextSearchVectorCommand,
private readonly enforceUniqueConstraintsCommand: EnforceUniqueConstraintsCommand,
private readonly deleteViewFieldsWithoutViewsCommand: DeleteViewFieldsWithoutViewsCommand,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly setMissingLabelIdentifierToCustomObjectsCommand: SetMissingLabelIdentifierToCustomObjectsCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_33CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.deleteViewFieldsWithoutViewsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.enforceUniqueConstraintsCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
company: true,
person: true,
viewField: true,
viewSort: true,
},
workspaceIds,
);
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
await this.updateRichTextSearchVectorCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.setMissingLabelIdentifierToCustomObjectsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -1,35 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DeleteViewFieldsWithoutViewsCommand } from 'src/database/commands/upgrade-version/0-33/0-33-delete-view-fields-without-views.command';
import { EnforceUniqueConstraintsCommand } from 'src/database/commands/upgrade-version/0-33/0-33-enforce-unique-constraints.command';
import { SetMissingLabelIdentifierToCustomObjectsCommand } from 'src/database/commands/upgrade-version/0-33/0-33-set-missing-label-identifier-to-custom-objects.command';
import { UpdateRichTextSearchVectorCommand } from 'src/database/commands/upgrade-version/0-33/0-33-update-rich-text-search-vector-expression';
import { UpgradeTo0_33Command } from 'src/database/commands/upgrade-version/0-33/0-33-upgrade-version.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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 { SearchModule } from 'src/engine/metadata-modules/search/search.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
WorkspaceSyncMetadataCommandsModule,
SearchModule,
WorkspaceMigrationRunnerModule,
],
providers: [
UpgradeTo0_33Command,
UpdateRichTextSearchVectorCommand,
EnforceUniqueConstraintsCommand,
DeleteViewFieldsWithoutViewsCommand,
SetMissingLabelIdentifierToCustomObjectsCommand,
],
})
export class UpgradeTo0_33CommandModule {}

View File

@ -1,143 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
// For DX only
type WorkspaceId = string;
type Subdomain = string;
@Command({
name: 'feat-0.34:add-subdomain-to-workspace',
description: 'Add a default subdomain to each workspace',
})
export class GenerateDefaultSubdomainCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
) {
super(workspaceRepository);
}
private generatePayloadForQuery({
id,
subdomain,
domainName,
displayName,
}: Workspace) {
const result = { id, subdomain };
if (domainName) {
const subdomain = domainName.split('.')[0];
if (subdomain.length > 0) {
result.subdomain = subdomain;
}
}
if (!domainName && displayName) {
result.subdomain = this.sanitizeForSubdomain(displayName);
}
return result;
}
private sanitizeForSubdomain(input: string) {
const normalized = input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
const hyphenated = normalized
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase();
// DNS subdomain limit is 63, but we want to be conservative for duplicates
return hyphenated.substring(0, 60);
}
private groupBySubdomainName(
acc: Record<Subdomain, Array<WorkspaceId>>,
workspace: Workspace,
) {
const payload = this.generatePayloadForQuery(workspace);
acc[payload.subdomain] = acc[payload.subdomain]
? acc[payload.subdomain].concat([payload.id])
: [payload.id];
return acc;
}
private async deduplicateAndSave(
subdomain: Subdomain,
workspaceIds: Array<WorkspaceId>,
existingSubdomains: Set<string>,
options: BaseCommandOptions,
) {
for (const [index, workspaceId] of workspaceIds.entries()) {
const subdomainDeduplicated =
index === 0
? existingSubdomains.has(subdomain)
? `${subdomain}-${index}`
: subdomain
: `${subdomain}-${index}`;
this.logger.log(
`Updating workspace ${workspaceId} with subdomain ${subdomainDeduplicated}`,
);
existingSubdomains.add(subdomainDeduplicated);
if (!options.dryRun) {
await this.workspaceRepository.update(workspaceId, {
subdomain: subdomainDeduplicated,
});
}
}
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: BaseCommandOptions,
activeWorkspaceIds: string[],
): Promise<void> {
const workspaces = await this.workspaceRepository.find(
activeWorkspaceIds.length > 0
? {
where: {
id: In(activeWorkspaceIds),
},
}
: undefined,
);
if (workspaces.length === 0) {
this.logger.log('No workspaces found');
return;
}
const workspaceBySubdomain = Object.entries(
workspaces.reduce(
(acc, workspace) => this.groupBySubdomainName(acc, workspace),
{} as ReturnType<typeof this.groupBySubdomainName>,
),
);
const existingSubdomains: Set<string> = new Set();
for (const [subdomain, workspaceIds] of workspaceBySubdomain) {
await this.deduplicateAndSave(
subdomain,
workspaceIds,
existingSubdomains,
options,
);
}
}
}

View File

@ -1,38 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { GenerateDefaultSubdomainCommand } from 'src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command';
interface UpdateTo0_34CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.34',
description: 'Upgrade to 0.34',
})
export class UpgradeTo0_34Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly generateDefaultSubdomainCommand: GenerateDefaultSubdomainCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_34CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.generateDefaultSubdomainCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -1,26 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GenerateDefaultSubdomainCommand } from 'src/database/commands/upgrade-version/0-34/0-34-generate-subdomain.command';
import { UpgradeTo0_34Command } from 'src/database/commands/upgrade-version/0-34/0-34-upgrade-version.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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 { SearchModule } from 'src/engine/metadata-modules/search/search.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
WorkspaceSyncMetadataCommandsModule,
SearchModule,
WorkspaceMigrationRunnerModule,
],
providers: [UpgradeTo0_34Command, GenerateDefaultSubdomainCommand],
})
export class UpgradeTo0_34CommandModule {}

View File

@ -1,144 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { isCommandLogger } from 'src/database/commands/logger';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
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';
import { isDefined } from 'src/utils/is-defined';
@Command({
name: 'upgrade-0.35:phone-calling-code-create-column',
description: 'Create the callingCode column',
})
export class PhoneCallingCodeCreateColumnCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to add calling code and change country code with default one',
);
if (isCommandLogger(this.logger)) {
this.logger.setVerbose(options.verbose ?? false);
}
this.logger.verbose(`Part 1 - Workspace`);
let workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.verbose(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
this.logger.verbose(
`P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`,
);
try {
const phonesFieldMetadata = await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.PHONES,
},
relations: ['object'],
});
for (const phoneFieldMetadata of phonesFieldMetadata) {
if (
isDefined(phoneFieldMetadata?.name) &&
isDefined(phoneFieldMetadata.object)
) {
this.logger.verbose(
`P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`,
);
if (!phoneFieldMetadata.object?.nameSingular) continue;
this.logger.verbose(
`P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`,
);
if (options.dryRun) {
continue;
}
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(phoneFieldMetadata.object),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
{
id: v4(),
type: FieldMetadataType.TEXT,
name: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
label: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
objectMetadataId: phoneFieldMetadata.object.id,
workspaceId: workspaceId,
isNullable: true,
defaultValue: "''",
} satisfies Partial<FieldMetadataEntity>,
),
} satisfies WorkspaceMigrationTableAction,
],
);
}
}
this.logger.verbose(
`P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
} catch (error) {
this.logger.log(`Error in workspace ${workspaceId} : ${error}`);
}
workspaceIterator++;
}
this.logger.log(chalk.green(`Command completed!`));
}
}

View File

@ -1,306 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { isCommandLogger } from 'src/database/commands/logger';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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 { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
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';
import { isDefined } from 'src/utils/is-defined';
const callingCodeToCountryCode = (callingCode: string): string => {
if (!callingCode) {
return '';
}
let callingCodeSanitized = callingCode;
if (callingCode.startsWith('+')) {
callingCodeSanitized = callingCode.slice(1);
}
return (
getCountries().find(
(countryCode) =>
getCountryCallingCode(countryCode) === callingCodeSanitized,
) || ''
);
};
const isCallingCode = (callingCode: string): boolean => {
return callingCodeToCountryCode(callingCode) !== '';
};
@Command({
name: 'upgrade-0.35:phone-calling-code-migrate-data',
description: 'Add calling code and change country code with default one',
})
export class PhoneCallingCodeMigrateDataCommand 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 twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to add calling code and change country code with default one',
);
if (isCommandLogger(this.logger)) {
this.logger.setVerbose(options.verbose ?? false);
}
this.logger.verbose(`Part 1 - Workspace`);
let workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.log(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
this.logger.verbose(
`P1 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`,
);
try {
const phonesFieldMetadata = await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.PHONES,
},
relations: ['object'],
});
for (const phoneFieldMetadata of phonesFieldMetadata) {
if (
isDefined(phoneFieldMetadata?.name) &&
isDefined(phoneFieldMetadata.object)
) {
this.logger.verbose(
`P1 Step 1 - Let's find the "nameSingular" of this objectMetadata: ${phoneFieldMetadata.object.nameSingular || 'not found'}`,
);
if (!phoneFieldMetadata.object?.nameSingular) continue;
this.logger.verbose(
`P1 Step 1 - Create migration for field ${phoneFieldMetadata.name}`,
);
if (!options.dryRun) {
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`create-${phoneFieldMetadata.object.nameSingular}PrimaryPhoneCallingCode-for-field-${phoneFieldMetadata.name}`,
),
workspaceId,
[
{
name: computeObjectTargetTable(phoneFieldMetadata.object),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
{
id: v4(),
type: FieldMetadataType.TEXT,
name: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
label: `${phoneFieldMetadata.name}PrimaryPhoneCallingCode`,
objectMetadataId: phoneFieldMetadata.object.id,
workspaceId: workspaceId,
isNullable: true,
defaultValue: "''",
},
),
} satisfies WorkspaceMigrationTableAction,
],
);
}
}
}
this.logger.verbose(
`P1 Step 1 - RUN migration to create callingCodes for ${workspaceId.slice(0, 5)}`,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.verbose(
`P1 Step 2 - Migrations for callingCode must be first. Now can use twentyORMGlobalManager to update countryCode`,
);
this.logger.verbose(
`P1 Step 3 (same time) - update CountryCode to letters: +33 => FR || +1 => US (if mulitple, first one)`,
);
this.logger.verbose(
`P1 Step 4 (same time) - update all additioanl phones to add a country code following the same logic`,
);
for (const phoneFieldMetadata of phonesFieldMetadata) {
this.logger.verbose(`P1 Step 2 - for ${phoneFieldMetadata.name}`);
if (
isDefined(phoneFieldMetadata) &&
isDefined(phoneFieldMetadata.name)
) {
const [objectMetadata] = await this.objectMetadataRepository.find({
where: {
id: phoneFieldMetadata?.objectMetadataId,
},
});
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectMetadata.nameSingular,
);
const records = await repository.find();
for (const record of records) {
if (
record?.[phoneFieldMetadata.name]?.primaryPhoneCountryCode &&
isCallingCode(
record[phoneFieldMetadata.name].primaryPhoneCountryCode,
)
) {
let additionalPhones = null;
if (record[phoneFieldMetadata.name].additionalPhones) {
additionalPhones = record[
phoneFieldMetadata.name
].additionalPhones.map((phone) => {
return {
...phone,
countryCode: callingCodeToCountryCode(phone.callingCode),
};
});
}
if (!options.dryRun) {
await repository.update(record.id, {
[`${phoneFieldMetadata.name}PrimaryPhoneCallingCode`]:
record[phoneFieldMetadata.name].primaryPhoneCountryCode,
[`${phoneFieldMetadata.name}PrimaryPhoneCountryCode`]:
callingCodeToCountryCode(
record[phoneFieldMetadata.name].primaryPhoneCountryCode,
),
[`${phoneFieldMetadata.name}AdditionalPhones`]:
additionalPhones,
});
}
}
}
}
}
} catch (error) {
this.logger.log(`Error in workspace ${workspaceId} : ${error}`);
}
workspaceIterator++;
}
this.logger.verbose(`
Part 2 - FieldMetadata`);
workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.log(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
this.logger.verbose(
`P2 Step 1 - let's find all the fieldsMetadata that have the PHONES type, and extract the objectMetadataId`,
);
try {
const phonesFieldMetadata = await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.PHONES,
},
});
for (const phoneFieldMetadata of phonesFieldMetadata) {
if (
!isDefined(phoneFieldMetadata) ||
!isDefined(phoneFieldMetadata.defaultValue)
)
continue;
let defaultValue = phoneFieldMetadata.defaultValue;
// some cases look like it's an array. let's flatten it (not sure the case is supposed to happen but I saw it in my local db)
if (Array.isArray(defaultValue) && isDefined(defaultValue[0]))
defaultValue = phoneFieldMetadata.defaultValue[0];
if (!isDefined(defaultValue)) continue;
if (typeof defaultValue !== 'object') continue;
if (!('primaryPhoneCountryCode' in defaultValue)) continue;
if (!defaultValue.primaryPhoneCountryCode) continue;
const primaryPhoneCountryCode = defaultValue.primaryPhoneCountryCode;
const countryCode = callingCodeToCountryCode(
primaryPhoneCountryCode.replace(/["']/g, ''),
);
if (!options.dryRun) {
if (!defaultValue.primaryPhoneCallingCode) {
await this.fieldMetadataRepository.update(phoneFieldMetadata.id, {
defaultValue: {
...defaultValue,
primaryPhoneCountryCode: countryCode
? `'${countryCode}'`
: "''",
primaryPhoneCallingCode: isCallingCode(
primaryPhoneCountryCode.replace(/["']/g, ''),
)
? primaryPhoneCountryCode
: "''",
},
});
}
}
}
} catch (error) {
this.logger.log(`Error in workspace ${workspaceId} : ${error}`);
}
workspaceIterator++;
}
this.logger.log(chalk.green(`Command completed!`));
}
}

View File

@ -1,42 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { RecordPositionBackfillService } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Command({
name: 'upgrade-0.35:record-position-backfill',
description: 'Backfill record position',
})
export class RecordPositionBackfillCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly recordPositionBackfillService: RecordPositionBackfillService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: BaseCommandOptions,
workspaceIds: string[],
): Promise<void> {
for (const workspaceId of workspaceIds) {
try {
await this.recordPositionBackfillService.backfill(
workspaceId,
options.dryRun ?? false,
);
} catch (error) {
this.logger.error(
`Error backfilling record position for workspace ${workspaceId}: ${error}`,
);
}
}
}
}

View File

@ -1,74 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command';
import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command';
import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command';
import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
@Command({
name: 'upgrade-0.35',
description: 'Upgrade to 0.35',
})
export class UpgradeTo0_35Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly viewGroupNoValueBackfillCommand: ViewGroupNoValueBackfillCommand,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly phoneCallingCodeMigrateDataCommand: PhoneCallingCodeMigrateDataCommand,
private readonly phoneCallingCodeCreateColumnCommand: PhoneCallingCodeCreateColumnCommand,
private readonly recordPositionBackfillCommand: RecordPositionBackfillCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: BaseCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to upgrade to 0.35: must start with phone calling code otherwise SyncMetadata will fail',
);
await this.recordPositionBackfillCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.phoneCallingCodeCreateColumnCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.phoneCallingCodeMigrateDataCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.viewGroupNoValueBackfillCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
}
}

View File

@ -1,46 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PhoneCallingCodeCreateColumnCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-create-column.command';
import { PhoneCallingCodeMigrateDataCommand } from 'src/database/commands/upgrade-version/0-35/0-35-phone-calling-code-migrate-data.command';
import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-record-position-backfill.command';
import { UpgradeTo0_35Command } from 'src/database/commands/upgrade-version/0-35/0-35-upgrade-version.command';
import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-35/0-35-view-group-no-value-backfill.command';
import { RecordPositionBackfillModule } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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 { SearchModule } from 'src/engine/metadata-modules/search/search.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
'metadata',
),
TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'),
WorkspaceSyncMetadataCommandsModule,
SearchModule,
WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule,
WorkspaceMigrationModule,
RecordPositionBackfillModule,
FieldMetadataModule,
],
providers: [
UpgradeTo0_35Command,
PhoneCallingCodeCreateColumnCommand,
PhoneCallingCodeMigrateDataCommand,
WorkspaceMigrationFactory,
RecordPositionBackfillCommand,
ViewGroupNoValueBackfillCommand,
],
})
export class UpgradeTo0_35CommandModule {}

View File

@ -1,88 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@Command({
name: 'migrate-0.35:backfill-view-group-no-value',
description: 'Backfill view group no value',
})
export class ViewGroupNoValueBackfillCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: BaseCommandOptions,
workspaceIds: string[],
): Promise<void> {
for (const workspaceId of workspaceIds) {
try {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
);
const viewGroupRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewGroupWorkspaceEntity>(
workspaceId,
'viewGroup',
);
const views = await viewRepository.find({
relations: ['viewGroups'],
});
for (const view of views) {
if (view.viewGroups.length === 0) {
continue;
}
// We're assuming for now that all viewGroups belonging to the same view have the same fieldMetadataId
const viewGroup = view.viewGroups?.[0];
const fieldMetadataId = viewGroup?.fieldMetadataId;
if (!fieldMetadataId || !viewGroup) {
continue;
}
const fieldMetadata = await this.fieldMetadataRepository.findOne({
where: { id: viewGroup.fieldMetadataId },
});
if (!fieldMetadata) {
continue;
}
await this.fieldMetadataRelatedRecordsService.syncNoValueViewGroup(
fieldMetadata,
view,
viewGroupRepository,
);
}
} catch (error) {
this.logger.error(
`Error backfilling view group no value for workspace ${workspaceId}: ${error}`,
);
}
}
}
}

View File

@ -0,0 +1,250 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, Option } from 'nest-commander';
import { In, Repository } from 'typeorm';
import {
BaseCommandOptions,
BaseCommandRunner,
} from 'src/database/commands/base.command';
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
type UpdateInactiveWorkspaceStatusOptions = BaseCommandOptions & {
workspaceIds: string[];
};
@Command({
name: 'upgrade-0.40:update-inactive-workspace-status',
description:
'Update the status of inactive workspaces to SUSPENDED and delete them',
})
export class UpdateInactiveWorkspaceStatusCommand extends BaseCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(DataSourceEntity, 'metadata')
protected readonly datasourceRepository: Repository<DataSourceEntity>,
@InjectRepository(WorkspaceMigrationEntity, 'metadata')
protected readonly workspaceMigrationRepository: Repository<WorkspaceMigrationEntity>,
@InjectRepository(BillingSubscription, 'core')
protected readonly subscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
protected readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(KeyValuePair, 'core')
protected readonly keyValuePairRepository: Repository<KeyValuePair>,
@InjectRepository(UserWorkspace, 'core')
protected readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(User, 'core')
protected readonly userRepository: Repository<User>,
private readonly typeORMService: TypeORMService,
) {
super();
}
@Option({
flags: '-w, --workspace-ids [workspaceIds]',
description: 'Workspace ids to process (comma separated)',
})
parseWorkspaceIds(val: string): string[] {
return val.split(',');
}
override async executeBaseCommand(
_passedParams: string[],
options: UpdateInactiveWorkspaceStatusOptions,
): Promise<void> {
const whereCondition: any = {
activationStatus: WorkspaceActivationStatus.INACTIVE,
};
if (options.workspaceIds?.length > 0) {
whereCondition.id = In(options.workspaceIds);
}
const workspaces = await this.workspaceRepository.find({
where: whereCondition,
});
if (options.dryRun) {
this.logger.log(chalk.yellow('Dry run mode: No changes will be applied'));
}
this.logger.log(
chalk.blue(
`Found ${workspaces.length} inactive workspace${
workspaces.length > 1 ? 's' : ''
}`,
),
);
await rawDataSource.initialize();
for (const workspace of workspaces) {
this.logger.log(
chalk.blue(
`Processing workspace ${workspace.id} with name ${workspace.displayName}`,
),
);
// Check if the workspace has a datasource
const datasource = await this.datasourceRepository.findOne({
where: { workspaceId: workspace.id },
});
const schemaName = datasource?.schema;
const postgresSchemaExists = await this.typeORMService
.getMainDataSource()
.query(
`SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '${schemaName}'`,
);
if (!schemaName || !postgresSchemaExists) {
await this.deleteWorkspaceAndMarkAsSuspended(workspace, options);
continue;
}
const subscriptions = await this.subscriptionRepository.find({
where: { workspaceId: workspace.id },
});
if (subscriptions.length > 1) {
this.logger.warn(chalk.red('More than one subscription found'));
continue;
}
const subscription = subscriptions[0];
if (!subscription) {
this.logger.log(chalk.red('No subscription found'));
await this.deleteWorkspaceAndMarkAsSuspendedAndDeleteAllData(
workspace,
schemaName,
options,
);
continue;
}
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
if (
([
SubscriptionStatus.Canceled,
SubscriptionStatus.Incomplete,
SubscriptionStatus.IncompleteExpired,
SubscriptionStatus.Unpaid,
SubscriptionStatus.Paused,
].includes(subscription.status) &&
subscription.canceledAt &&
subscription.canceledAt < thirtyDaysAgo) ||
(subscription.canceledAt === null &&
subscription.updatedAt &&
subscription.updatedAt < thirtyDaysAgo)
) {
await this.deleteWorkspaceAndMarkAsSuspendedAndDeleteAllData(
workspace,
schemaName,
options,
subscription,
);
continue;
}
await this.markAsSuspended(workspace, options);
}
}
private async deleteWorkspaceAndMarkAsSuspended(
workspace: Workspace,
options: UpdateInactiveWorkspaceStatusOptions,
) {
this.logger.log(
chalk.blue('(!!) Deleting workspace and marking as suspended'),
);
if (!options.dryRun) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
});
await this.workspaceRepository.softRemove({ id: workspace.id });
}
}
private async deleteWorkspaceAndMarkAsSuspendedAndDeleteAllData(
workspace: Workspace,
schemaName: string,
options: UpdateInactiveWorkspaceStatusOptions,
billingSubscription?: BillingSubscription,
) {
this.logger.warn(
chalk.blue(
`(!!!) Deleting workspace and marking as suspended and deleting all data for workspace updated at ${workspace.updatedAt} with subscription status ${billingSubscription?.status} and subscription updatedAt ${billingSubscription?.updatedAt} and canceledAt ${billingSubscription?.canceledAt}`,
),
);
if (!options.dryRun) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
});
await this.workspaceRepository.softRemove({ id: workspace.id });
await this.datasourceRepository.delete({ workspaceId: workspace.id });
await this.workspaceMigrationRepository.delete({
workspaceId: workspace.id,
});
await this.featureFlagRepository.delete({ workspaceId: workspace.id });
await this.keyValuePairRepository.delete({ workspaceId: workspace.id });
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { workspaceId: workspace.id },
});
for (const userWorkspace of userWorkspaces) {
await this.userWorkspaceRepository.delete({ id: userWorkspace.id });
const remainingUserWorkspaces =
await this.userWorkspaceRepository.count({
where: { userId: userWorkspace.userId },
});
if (remainingUserWorkspaces === 0) {
await this.userRepository.softRemove({ id: userWorkspace.userId });
}
}
await this.typeORMService
.getMainDataSource()
.query(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`);
}
}
private async markAsSuspended(
workspace: Workspace,
options: UpdateInactiveWorkspaceStatusOptions,
) {
this.logger.log(chalk.blue('(!) Marking as suspended'));
if (!options.dryRun) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
});
}
}
}

View File

@ -2,25 +2,54 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MigrateAggregateOperationOptionsCommand } from 'src/database/commands/upgrade-version/0-40/0-40-migrate-aggregate-operations-options.command';
import { UpdateInactiveWorkspaceStatusCommand } from 'src/database/commands/upgrade-version/0-40/0-40-update-inactive-workspace-status.command';
import { UpgradeTo0_40Command } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command';
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';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.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 { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, FieldMetadataEntity],
[
Workspace,
BillingSubscription,
FeatureFlagEntity,
KeyValuePair,
User,
UserWorkspace,
],
'core',
),
TypeOrmModule.forFeature(
[
ObjectMetadataEntity,
FieldMetadataEntity,
DataSourceEntity,
WorkspaceMigrationEntity,
],
'metadata',
),
WorkspaceMigrationRunnerModule,
WorkspaceMigrationModule,
WorkspaceMetadataVersionModule,
TypeORMModule,
],
providers: [
UpgradeTo0_40Command,
MigrateAggregateOperationOptionsCommand,
UpdateInactiveWorkspaceStatusCommand,
],
providers: [UpgradeTo0_40Command, MigrateAggregateOperationOptionsCommand],
})
export class UpgradeTo0_40CommandModule {}

View File

@ -4,6 +4,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
OneToMany,
@ -77,7 +78,7 @@ export class User {
updatedAt: Date;
@Field({ nullable: true })
@Column({ nullable: true, type: 'timestamptz' })
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date;
@OneToMany(() => AppToken, (appToken) => appToken.user, {

View File

@ -4,6 +4,7 @@ import { IDField, UnPagedRelation } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
@ -67,7 +68,7 @@ export class Workspace {
inviteHash?: string;
@Field({ nullable: true })
@Column({ nullable: true, type: 'timestamptz' })
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@Field()