Files
twenty/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command.ts
T
Raphaël Bosi d341d0d624 Refactor navigation commands to use NAVIGATION engine key with payload (#19303)
- Adds a payload JSON column to `CommandMenuItem` and introduces a
unified `NAVIGATION` engine component key that replaces all individual
GO_TO_* keys
- Navigation commands now use the payload to determine their target
(either an objectMetadataItemId or a path), making navigation commands
dynamic and eliminating the need for a hardcoded engine key per object
- Includes a 1.21 upgrade command (refactor-navigation-commands) that
migrates existing GO_TO_* items to NAVIGATION items with the appropriate
payload, and applies a CHECK constraint enforcing payload coherence



https://github.com/user-attachments/assets/4d305ba2-ae0b-4556-bb0e-e9d899777350


TODO: In a second PR, create the sync between object metadata items and
the navigation command menu items
- Object metadata item created or enabled -> Create navigation command
- Object metadata item deleted or disabled -> Delete associated
navigation command

In another PR:
- Allow `label`, `shortLabel` and `icon` to resolve the
`navigateToObjectMetadataItem` dynamically in their interpolation
instead of being hardcoded in the command menu item
- Make the icon dynamic in the command menu items as the label so that
we can resolve ${navigateToObjectMetadataItem.icon} at runTime -> This
way we won't need to keep update the command menu item icon when we
update the objectMetadataItem icon
2026-04-07 15:00:04 +00:00

380 lines
14 KiB
TypeScript

import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
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 and trigger workflow version command menu items for existing workspaces and enable IS_COMMAND_MENU_ITEM_ENABLED feature flag',
})
export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
protected readonly workspaceIteratorService: WorkspaceIteratorService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of command menu items for workspace ${workspaceId}`,
);
const isFeatureFlagAlreadyEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (isFeatureFlagAlreadyEnabled) {
this.logger.log(
`IS_COMMAND_MENU_ITEM_ENABLED already enabled for workspace ${workspaceId}, skipping`,
);
return;
}
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const standardCommandMenuItems =
await this.computeStandardCommandMenuItemsToCreate(
workspaceId,
twentyStandardFlatApplication,
);
const triggerWorkflowVersionCommandMenuItems =
await this.computeTriggerWorkflowVersionCommandMenuItemsToCreate(
workspaceId,
workspaceCustomFlatApplication,
);
const totalCount =
standardCommandMenuItems.length +
triggerWorkflowVersionCommandMenuItems.length;
if (totalCount === 0) {
this.logger.log(
`No missing command menu items for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${totalCount} missing command menu item(s) for workspace ${workspaceId} (${standardCommandMenuItems.length} standard, ${triggerWorkflowVersionCommandMenuItems.length} trigger workflow version)`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${totalCount} command menu item(s) for workspace ${workspaceId}`,
);
return;
}
if (standardCommandMenuItems.length > 0) {
await this.createCommandMenuItems({
workspaceId,
flatCommandMenuItemsToCreate: standardCommandMenuItems,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
});
}
if (triggerWorkflowVersionCommandMenuItems.length > 0) {
await this.createCommandMenuItems({
workspaceId,
flatCommandMenuItemsToCreate: triggerWorkflowVersionCommandMenuItems,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
}
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED],
workspaceId,
);
this.logger.log(
`Successfully backfilled ${totalCount} command menu item(s) for workspace ${workspaceId}`,
);
}
private async computeStandardCommandMenuItemsToCreate(
workspaceId: string,
twentyStandardFlatApplication: FlatApplication,
): Promise<FlatCommandMenuItem[]> {
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
]);
const commandMenuItemsToCreate = Object.values(
standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(commandMenuItem) =>
!isDefined(
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
commandMenuItem.universalIdentifier
],
),
);
if (commandMenuItemsToCreate.length > 0) {
this.logger.log(
`Found ${commandMenuItemsToCreate.length} missing standard command menu item(s) for workspace ${workspaceId}`,
);
}
return commandMenuItemsToCreate;
}
private async computeTriggerWorkflowVersionCommandMenuItemsToCreate(
workspaceId: string,
workspaceCustomFlatApplication: FlatApplication,
): Promise<FlatCommandMenuItem[]> {
const authContext = buildSystemAuthContext(workspaceId);
return await this.twentyORMGlobalManager.executeInWorkspaceContext(
async () => {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepository<WorkflowVersionWorkspaceEntity>(
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<WorkflowWorkspaceEntity>(
workspaceId,
'workflow',
{ shouldBypassPermissionChecks: true },
);
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,
availabilityObjectMetadataUniversalIdentifier,
} = 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: EngineComponentKey.TRIGGER_WORKFLOW_VERSION,
label,
shortLabel: label,
icon: trigger.settings.icon ?? null,
isPinned: trigger.settings.isPinned ?? false,
position: 0,
payload: null,
hotKeys: null,
availabilityType,
availabilityObjectMetadataId: availabilityObjectMetadataId ?? null,
availabilityObjectMetadataUniversalIdentifier,
conditionalAvailabilityExpression: null,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
createdAt: now,
updatedAt: now,
});
}
if (flatCommandMenuItemsToCreate.length > 0) {
this.logger.log(
`Found ${flatCommandMenuItemsToCreate.length} missing trigger workflow version command menu item(s) for workspace ${workspaceId}`,
);
}
return flatCommandMenuItemsToCreate;
},
authContext,
);
}
private async createCommandMenuItems({
workspaceId,
flatCommandMenuItemsToCreate,
applicationUniversalIdentifier,
}: {
workspaceId: string;
flatCommandMenuItemsToCreate: FlatCommandMenuItem[];
applicationUniversalIdentifier: string;
}): Promise<void> {
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill command menu items:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill command menu items for workspace ${workspaceId}`,
);
}
}
private async resolveManualTriggerAvailability(
trigger: WorkflowManualTrigger,
workspaceId: string,
): Promise<{
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId: string | undefined;
availabilityObjectMetadataUniversalIdentifier: string | null;
}> {
const availability = trigger.settings.availability;
if (!isDefined(availability) || availability.type === 'GLOBAL') {
return {
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId: undefined,
availabilityObjectMetadataUniversalIdentifier: null,
};
}
const { objectIdByNameSingular, flatObjectMetadataMaps } =
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,
availabilityObjectMetadataUniversalIdentifier: null,
};
}
const objectUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[objectId] ?? null;
return {
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: objectId,
availabilityObjectMetadataUniversalIdentifier: objectUniversalIdentifier,
};
}
}