Create command menu items for workflows with manual trigger (#18746)

- Automatically create and sync command menu items for all workflows
with a manual trigger
- Refactor `useCommandMenuItemsFromBackend`
- Prefill a _Quick Lead_ workflow command menu item during workspace
setup and dev seeding
- Add a ready prop to `HeadlessEngineCommandWrapperEffect` to prevent
premature execution when async data hasn't loaded yet

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2026-03-22 23:52:43 +01:00
committed by GitHub
parent d16b94bde6
commit c107d804d2
29 changed files with 1277 additions and 860 deletions
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
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 {
@@ -19,21 +21,32 @@ export class AutomatedTriggerWorkspaceService {
type,
settings,
workspaceId,
entityManager,
}: {
workflowId: string;
type: AutomatedTriggerType;
settings: AutomatedTriggerSettings;
workspaceId: string;
entityManager?: WorkspaceEntityManager;
}) {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
if (isDefined(entityManager)) {
await workflowAutomatedTriggerRepository.insert(
{ type, settings, workflowId },
entityManager,
);
return;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
await workflowAutomatedTriggerRepository.insert({
type,
settings,
@@ -45,19 +58,30 @@ export class AutomatedTriggerWorkspaceService {
async deleteAutomatedTrigger({
workflowId,
workspaceId,
entityManager,
}: {
workflowId: string;
workspaceId: string;
entityManager?: WorkspaceEntityManager;
}) {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
if (isDefined(entityManager)) {
await workflowAutomatedTriggerRepository.delete(
{ workflowId },
entityManager,
);
return;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
await workflowAutomatedTriggerRepository.delete({ workflowId });
}, authContext);
}
@@ -40,6 +40,7 @@ export type WorkflowManualTrigger = BaseTrigger & {
settings: BaseWorkflowTriggerSettings & {
objectType?: string;
icon?: string;
isPinned?: boolean;
availability?:
| GlobalAvailability
| SingleRecordAvailability
@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type ActorMetadata, FeatureFlagKey } from 'twenty-shared/types';
@@ -7,6 +6,7 @@ import { type ActorMetadata, FeatureFlagKey } from 'twenty-shared/types';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -39,6 +39,8 @@ import { assertNever } from 'src/utils/assert';
@Injectable()
export class WorkflowTriggerWorkspaceService {
private readonly logger = new Logger(WorkflowTriggerWorkspaceService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
@@ -183,31 +185,96 @@ export class WorkflowTriggerWorkspaceService {
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
workspaceId: string,
) {
const previousPublishedVersionId = workflow.lastPublishedVersionId;
if (
workflow.lastPublishedVersionId &&
workflowVersion.id !== workflow.lastPublishedVersionId
previousPublishedVersionId &&
workflowVersion.id !== previousPublishedVersionId
) {
await this.performDeactivationSteps(
workflow.lastPublishedVersionId,
previousPublishedVersionId,
workflowVersionRepository,
workspaceId,
);
}
await this.upgradeWorkflowVersion(
await this.createOrUpdateCommandMenuItem(
workflow,
workflowVersion.id,
workflowRepository,
workflowVersionRepository,
);
await this.setActiveVersionStatus(
workflowVersion,
workflowVersionRepository,
workspaceId,
);
await this.enableTrigger(workflow, workflowVersion, workspaceId);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
const queryRunner = workspaceDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
if (workflow.lastPublishedVersionId !== workflowVersion.id) {
if (workflow.lastPublishedVersionId) {
await workflowVersionRepository.update(
{ id: workflow.lastPublishedVersionId },
{ status: WorkflowVersionStatus.ARCHIVED },
queryRunner.manager,
);
}
await workflowRepository.update(
{ id: workflow.id },
{ lastPublishedVersionId: workflowVersion.id },
queryRunner.manager,
);
}
const activeWorkflowVersions = await workflowVersionRepository.find(
{
where: {
workflowId: workflowVersion.workflowId,
status: WorkflowVersionStatus.ACTIVE,
},
},
queryRunner.manager,
);
if (activeWorkflowVersions.length > 0) {
throw new WorkflowTriggerException(
'Cannot have more than one active workflow version',
WorkflowTriggerExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot have more than one active workflow version`,
},
);
}
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.ACTIVE },
queryRunner.manager,
);
await this.enableAutomatedTrigger(workflowVersion, workspaceId, {
entityManager: queryRunner.manager,
});
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
await this.emitStatusUpdateEvents(
workflowVersion,
WorkflowVersionStatus.ACTIVE,
workspaceId,
);
}
private async performDeactivationSteps(
@@ -228,69 +295,38 @@ export class WorkflowTriggerWorkspaceService {
return;
}
await this.setDeactivatedVersionStatus(
workflowVersion,
workflowVersionRepository,
workspaceId,
);
await this.deleteCommandMenuItem(workflowVersion, workspaceId);
await this.disableTrigger(workflowVersion, workspaceId);
}
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
private async setActiveVersionStatus(
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
workspaceId: string,
) {
const activeWorkflowVersions = await workflowVersionRepository.find({
where: {
workflowId: workflowVersion.workflowId,
status: WorkflowVersionStatus.ACTIVE,
},
});
const queryRunner = workspaceDataSource.createQueryRunner();
if (activeWorkflowVersions.length > 0) {
throw new WorkflowTriggerException(
'Cannot have more than one active workflow version',
WorkflowTriggerExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot have more than one active workflow version`,
},
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.DEACTIVATED },
queryRunner.manager,
);
await this.disableAutomatedTrigger(workflowVersion, workspaceId, {
entityManager: queryRunner.manager,
});
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.ACTIVE },
);
await this.emitStatusUpdateEvents(
workflowVersion,
WorkflowVersionStatus.ACTIVE,
workspaceId,
);
}
private async setDeactivatedVersionStatus(
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
workspaceId: string,
) {
if (workflowVersion.status !== WorkflowVersionStatus.ACTIVE) {
throw new WorkflowTriggerException(
'Cannot disable non-active workflow version',
WorkflowTriggerExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot disable non-active workflow version`,
},
);
}
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.DEACTIVATED },
);
await this.emitStatusUpdateEvents(
workflowVersion,
WorkflowVersionStatus.DEACTIVATED,
@@ -298,101 +334,161 @@ export class WorkflowTriggerWorkspaceService {
);
}
private async upgradeWorkflowVersion(
workflow: WorkflowWorkspaceEntity,
newPublishedVersionId: string,
workflowRepository: WorkspaceRepository<WorkflowWorkspaceEntity>,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
) {
if (workflow.lastPublishedVersionId === newPublishedVersionId) {
return;
private async resolveManualTriggerAvailability(
trigger: WorkflowManualTrigger,
workspaceId: string,
): Promise<{
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId: string | undefined;
}> {
const availability = trigger.settings.availability;
let availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
let availabilityObjectMetadataId: string | undefined;
if (availability) {
switch (availability.type) {
case 'GLOBAL':
availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
break;
case 'SINGLE_RECORD':
case 'BULK_RECORDS': {
availabilityType = CommandMenuItemAvailabilityType.RECORD_SELECTION;
const { objectIdByNameSingular } =
await this.workflowCommonWorkspaceService.getFlatEntityMaps(
workspaceId,
);
const objectId =
objectIdByNameSingular[availability.objectNameSingular];
if (!objectId) {
throw new WorkflowTriggerException(
`Object metadata not found for object: ${availability.objectNameSingular}`,
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
availabilityObjectMetadataId = objectId;
break;
}
}
}
if (workflow.lastPublishedVersionId) {
await workflowVersionRepository.update(
{ id: workflow.lastPublishedVersionId },
{ status: WorkflowVersionStatus.ARCHIVED },
);
}
await workflowRepository.update(
{ id: workflow.id },
{ lastPublishedVersionId: newPublishedVersionId },
);
return { availabilityType, availabilityObjectMetadataId };
}
private async enableTrigger(
private async createOrUpdateCommandMenuItem(
workflow: WorkflowWorkspaceEntity,
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
if (workflowVersion.trigger.type !== WorkflowTriggerType.MANUAL) {
return;
}
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const trigger = workflowVersion.trigger as WorkflowManualTrigger;
const { availabilityType, availabilityObjectMetadataId } =
await this.resolveManualTriggerAvailability(trigger, workspaceId);
const label = isNonEmptyString(workflow.name)
? workflow.name
: 'Manual Trigger';
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersion.id,
workspaceId,
);
if (existingCommandMenuItem) {
await this.commandMenuItemService.update(
{
id: existingCommandMenuItem.id,
label,
shortLabel: label,
icon: trigger.settings.icon,
isPinned: trigger.settings.isPinned,
availabilityType,
availabilityObjectMetadataId,
},
workspaceId,
);
} else {
await this.commandMenuItemService.create(
{
workflowVersionId: workflowVersion.id,
label,
shortLabel: label,
icon: trigger.settings.icon,
isPinned: trigger.settings.isPinned,
availabilityType,
availabilityObjectMetadataId,
},
workspaceId,
);
}
}
private async deleteCommandMenuItem(
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
if (workflowVersion.trigger.type !== WorkflowTriggerType.MANUAL) {
return;
}
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersion.id,
workspaceId,
);
if (existingCommandMenuItem) {
await this.commandMenuItemService.delete(
existingCommandMenuItem.id,
workspaceId,
);
}
}
private async enableAutomatedTrigger(
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
transactionContext?: {
entityManager: WorkspaceEntityManager;
},
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
switch (workflowVersion.trigger.type) {
case WorkflowTriggerType.MANUAL: {
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const trigger = workflowVersion.trigger as WorkflowManualTrigger;
const availability = trigger.settings.availability;
let availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
let availabilityObjectMetadataId: string | undefined;
if (availability) {
switch (availability.type) {
case 'GLOBAL':
availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
break;
case 'SINGLE_RECORD':
case 'BULK_RECORDS': {
availabilityType =
CommandMenuItemAvailabilityType.RECORD_SELECTION;
const { objectIdByNameSingular } =
await this.workflowCommonWorkspaceService.getFlatEntityMaps(
workspaceId,
);
const objectId =
objectIdByNameSingular[availability.objectNameSingular];
if (!objectId) {
throw new WorkflowTriggerException(
`Object metadata not found for object: ${availability.objectNameSingular}`,
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
availabilityObjectMetadataId = objectId;
break;
}
}
}
await this.commandMenuItemService.create(
{
workflowVersionId: workflowVersion.id,
label: isNonEmptyString(workflow.name)
? workflow.name
: 'Manual Trigger',
icon: trigger.settings.icon,
availabilityType,
availabilityObjectMetadataId,
},
workspaceId,
);
return;
}
case WorkflowTriggerType.MANUAL:
case WorkflowTriggerType.WEBHOOK:
return;
case WorkflowTriggerType.DATABASE_EVENT: {
@@ -404,6 +500,7 @@ export class WorkflowTriggerWorkspaceService {
type: AutomatedTriggerType.DATABASE_EVENT,
settings,
workspaceId,
entityManager: transactionContext?.entityManager,
});
return;
@@ -416,19 +513,22 @@ export class WorkflowTriggerWorkspaceService {
type: AutomatedTriggerType.CRON,
settings: { pattern },
workspaceId,
entityManager: transactionContext?.entityManager,
});
return;
}
default: {
default:
assertNever(workflowVersion.trigger);
}
}
}
private async disableTrigger(
private async disableAutomatedTrigger(
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
transactionContext?: {
entityManager: WorkspaceEntityManager;
},
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
@@ -438,35 +538,11 @@ export class WorkflowTriggerWorkspaceService {
await this.automatedTriggerWorkspaceService.deleteAutomatedTrigger({
workflowId: workflowVersion.workflowId,
workspaceId,
entityManager: transactionContext?.entityManager,
});
return;
case WorkflowTriggerType.MANUAL: {
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersion.id,
workspaceId,
);
if (existingCommandMenuItem) {
await this.commandMenuItemService.delete(
existingCommandMenuItem.id,
workspaceId,
);
}
return;
}
case WorkflowTriggerType.MANUAL:
case WorkflowTriggerType.WEBHOOK:
return;
default: