diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command.ts index c903280377..f701091909 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command.ts @@ -1,25 +1,40 @@ -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { isNonEmptyString } from '@sniptt/guards'; import { Command } from 'nest-commander'; import { FeatureFlagKey } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { DataSource, type QueryRunner, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { + WorkflowVersionStatus, + type WorkflowVersionWorkspaceEntity, +} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; +import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { + type WorkflowManualTrigger, + WorkflowTriggerType, +} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; @Command({ name: 'upgrade:1-20:backfill-command-menu-items', description: - 'Backfill missing standard command menu items for existing workspaces and enable IS_COMMAND_MENU_ITEM_ENABLED feature flag', + 'Backfill missing standard and workflow command menu items for existing workspaces and enable IS_COMMAND_MENU_ITEM_ENABLED feature flag', }) export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { constructor( @@ -27,10 +42,13 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces protected readonly workspaceRepository: Repository, protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, protected readonly dataSourceService: DataSourceService, + @InjectDataSource() + private readonly coreDataSource: DataSource, private readonly applicationService: ApplicationService, private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly featureFlagService: FeatureFlagService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) { super(workspaceRepository, twentyORMGlobalManager, dataSourceService); } @@ -42,7 +60,7 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces const isDryRun = options.dryRun ?? false; this.logger.log( - `${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of missing standard command menu items for workspace ${workspaceId}`, + `${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of command menu items for workspace ${workspaceId}`, ); const isFeatureFlagAlreadyEnabled = @@ -59,6 +77,52 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces return; } + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + await this.backfillStandardCommandMenuItems( + workspaceId, + isDryRun, + queryRunner, + ); + + await this.backfillWorkflowCommandMenuItems( + workspaceId, + isDryRun, + queryRunner, + ); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Rolling back backfill of command menu items for workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } finally { + await queryRunner.release(); + } + + if (!isDryRun) { + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED], + workspaceId, + ); + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Completed backfill of command menu items for workspace ${workspaceId}`, + ); + } + + private async backfillStandardCommandMenuItems( + workspaceId: string, + isDryRun: boolean, + queryRunner: QueryRunner, + ): Promise { const { twentyStandardFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { workspaceId }, @@ -90,30 +154,21 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces ), ); - const numberOfCommandMenuItemsToCreate = commandMenuItemsToCreate.length; - - if (numberOfCommandMenuItemsToCreate === 0) { + if (commandMenuItemsToCreate.length === 0) { this.logger.log( - `No missing standard command menu items for workspace ${workspaceId}, enabling feature flag only`, + `No missing standard command menu items for workspace ${workspaceId}`, ); - if (!isDryRun) { - await this.featureFlagService.enableFeatureFlags( - [FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED], - workspaceId, - ); - } - return; } this.logger.log( - `Found ${numberOfCommandMenuItemsToCreate} missing standard command menu item(s) for workspace ${workspaceId}`, + `Found ${commandMenuItemsToCreate.length} missing standard command menu item(s) for workspace ${workspaceId}`, ); if (isDryRun) { this.logger.log( - `[DRY RUN] Would create ${numberOfCommandMenuItemsToCreate} command menu item(s) and enable IS_COMMAND_MENU_ITEM_ENABLED for workspace ${workspaceId}`, + `[DRY RUN] Would create ${commandMenuItemsToCreate.length} standard command menu item(s) for workspace ${workspaceId}`, ); return; @@ -132,6 +187,7 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces workspaceId, applicationUniversalIdentifier: twentyStandardFlatApplication.universalIdentifier, + queryRunner, }, ); @@ -145,13 +201,206 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaces ); } - await this.featureFlagService.enableFeatureFlags( - [FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED], - workspaceId, - ); - this.logger.log( - `Successfully backfilled ${commandMenuItemsToCreate.length} standard command menu item(s) and enabled IS_COMMAND_MENU_ITEM_ENABLED for workspace ${workspaceId}`, + `Successfully backfilled ${commandMenuItemsToCreate.length} standard command menu item(s) for workspace ${workspaceId}`, ); } + + private async backfillWorkflowCommandMenuItems( + workspaceId: string, + isDryRun: boolean, + queryRunner: QueryRunner, + ): Promise { + const authContext = buildSystemAuthContext(workspaceId); + + await this.twentyORMGlobalManager.executeInWorkspaceContext(async () => { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'workflowVersion', + { shouldBypassPermissionChecks: true }, + ); + + const activeWorkflowVersions = await workflowVersionRepository.find({ + where: { status: WorkflowVersionStatus.ACTIVE }, + }); + + const manualTriggerVersions = activeWorkflowVersions.filter( + (version) => + isDefined(version.trigger) && + version.trigger.type === WorkflowTriggerType.MANUAL, + ); + + if (manualTriggerVersions.length === 0) { + this.logger.log( + `No active workflow versions with manual triggers for workspace ${workspaceId}`, + ); + + return; + } + + const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatCommandMenuItemMaps', + ]); + + const existingWorkflowVersionIds = new Set( + Object.values(existingFlatCommandMenuItemMaps.byUniversalIdentifier) + .filter(isDefined) + .map((item) => item.workflowVersionId) + .filter(isDefined), + ); + + const workflowRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'workflow', + { shouldBypassPermissionChecks: true }, + ); + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = []; + + for (const workflowVersion of manualTriggerVersions) { + if (existingWorkflowVersionIds.has(workflowVersion.id)) { + continue; + } + + const workflow = await workflowRepository.findOne({ + where: { id: workflowVersion.workflowId }, + }); + + const label = + workflow && isNonEmptyString(workflow.name) + ? workflow.name + : 'Manual Trigger'; + + const trigger = workflowVersion.trigger as WorkflowManualTrigger; + + const { availabilityType, availabilityObjectMetadataId } = + await this.resolveManualTriggerAvailability(trigger, workspaceId); + + const id = uuidv4(); + const now = new Date().toISOString(); + + flatCommandMenuItemsToCreate.push({ + id, + universalIdentifier: id, + workflowVersionId: workflowVersion.id, + frontComponentId: null, + frontComponentUniversalIdentifier: null, + engineComponentKey: null, + label, + shortLabel: label, + icon: trigger.settings.icon ?? null, + isPinned: trigger.settings.isPinned ?? false, + position: 0, + hotKeys: null, + availabilityType, + availabilityObjectMetadataId: availabilityObjectMetadataId ?? null, + availabilityObjectMetadataUniversalIdentifier: null, + conditionalAvailabilityExpression: null, + workspaceId, + applicationId: workspaceCustomFlatApplication.id, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + createdAt: now, + updatedAt: now, + }); + } + + if (flatCommandMenuItemsToCreate.length === 0) { + this.logger.log( + `No missing workflow command menu items for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Found ${flatCommandMenuItemsToCreate.length} missing workflow command menu item(s) for workspace ${workspaceId}`, + ); + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would create ${flatCommandMenuItemsToCreate.length} workflow command menu item(s) for workspace ${workspaceId}`, + ); + + return; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: flatCommandMenuItemsToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + queryRunner, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to backfill workflow command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to backfill workflow command menu items for workspace ${workspaceId}`, + ); + } + + this.logger.log( + `Successfully backfilled ${flatCommandMenuItemsToCreate.length} workflow command menu item(s) for workspace ${workspaceId}`, + ); + }, authContext); + } + + private async resolveManualTriggerAvailability( + trigger: WorkflowManualTrigger, + workspaceId: string, + ): Promise<{ + availabilityType: CommandMenuItemAvailabilityType; + availabilityObjectMetadataId: string | undefined; + }> { + const availability = trigger.settings.availability; + + if (!isDefined(availability) || availability.type === 'GLOBAL') { + return { + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + availabilityObjectMetadataId: undefined, + }; + } + + const { objectIdByNameSingular } = + await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId); + + const objectId = objectIdByNameSingular[availability.objectNameSingular]; + + if (!isDefined(objectId)) { + this.logger.warn( + `Object metadata not found for "${availability.objectNameSingular}" in workspace ${workspaceId}, falling back to GLOBAL`, + ); + + return { + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + availabilityObjectMetadataId: undefined, + }; + } + + return { + availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, + availabilityObjectMetadataId: objectId, + }; + } } diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts index 3819ea817d..49b9196f42 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts @@ -31,6 +31,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; +import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ApplicationRegistrationModule, WorkspaceMigrationModule, FeatureFlagModule, + WorkflowCommonModule, ], providers: [ IdentifyPermissionFlagMetadataCommand,