feat: Add If/Else node (#16833)

Closes [#1265](https://github.com/twentyhq/core-team-issues/issues/1265)
This commit is contained in:
Abdul Rahman
2026-01-01 19:35:56 +05:30
committed by GitHub
parent 5d5fd5fca5
commit dff5e3cd7b
95 changed files with 1523 additions and 163 deletions
@@ -1,8 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import {
FieldMetadataType,
StepLogicalOperator,
ViewFilterOperand,
} from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import {
IF_ELSE_BRANCH_POSITION_OFFSETS,
type StepIfElseBranch,
} from 'twenty-shared/workflow';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -427,6 +435,48 @@ export class WorkflowVersionStepOperationsWorkspaceService {
additionalCreatedSteps: [emptyNodeStep],
};
}
case WorkflowActionType.IF_ELSE: {
const { ifEmptyNode, elseEmptyNode, ifFilterGroupId, branches } =
await this.createEmptyNodesForIfElseStep({
workflowVersionId,
workspaceId,
ifElsePosition: position,
});
const initialFilterId = v4();
return {
builtStep: {
...baseStep,
name: 'If/Else',
type: WorkflowActionType.IF_ELSE,
settings: {
...BASE_STEP_DEFINITION,
input: {
stepFilterGroups: [
{
id: ifFilterGroupId,
logicalOperator: StepLogicalOperator.AND,
},
],
stepFilters: [
{
id: initialFilterId,
type: 'unknown',
stepOutputKey: '',
operand: ViewFilterOperand.IS,
value: '',
stepFilterGroupId: ifFilterGroupId,
positionInStepFilterGroup: 0,
},
],
branches,
},
},
},
additionalCreatedSteps: [ifEmptyNode, elseEmptyNode],
};
}
case WorkflowActionType.DELAY: {
return {
builtStep: {
@@ -448,6 +498,20 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
};
}
case WorkflowActionType.EMPTY: {
return {
builtStep: {
...baseStep,
name: 'Add an Action',
type: WorkflowActionType.EMPTY,
valid: true,
settings: {
...BASE_STEP_DEFINITION,
input: {},
},
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,
@@ -715,6 +779,107 @@ export class WorkflowVersionStepOperationsWorkspaceService {
);
}
async createEmptyNodesForIfElseStep({
workflowVersionId,
workspaceId,
ifElsePosition,
}: {
workflowVersionId: string;
workspaceId: string;
ifElsePosition?: WorkflowStepPositionInput;
}): Promise<{
ifEmptyNode: WorkflowAction;
elseEmptyNode: WorkflowAction;
ifFilterGroupId: string;
branches: StepIfElseBranch[];
}> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!isDefined(workflowVersion)) {
throw new WorkflowVersionStepException(
'WorkflowVersion not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const existingSteps = workflowVersion.steps ?? [];
const ifEmptyNode: WorkflowEmptyAction = {
id: v4(),
name: 'Add an Action',
type: WorkflowActionType.EMPTY,
valid: true,
settings: {
...BASE_STEP_DEFINITION,
input: {},
},
position: {
x: (ifElsePosition?.x ?? 0) + IF_ELSE_BRANCH_POSITION_OFFSETS.IF.x,
y: (ifElsePosition?.y ?? 0) + IF_ELSE_BRANCH_POSITION_OFFSETS.IF.y,
},
};
const elseEmptyNode: WorkflowEmptyAction = {
id: v4(),
name: 'Add an Action',
type: WorkflowActionType.EMPTY,
valid: true,
settings: {
...BASE_STEP_DEFINITION,
input: {},
},
position: {
x:
(ifElsePosition?.x ?? 0) + IF_ELSE_BRANCH_POSITION_OFFSETS.ELSE.x,
y:
(ifElsePosition?.y ?? 0) + IF_ELSE_BRANCH_POSITION_OFFSETS.ELSE.y,
},
};
await workflowVersionRepository.update(workflowVersion.id, {
steps: [...existingSteps, ifEmptyNode, elseEmptyNode],
});
const ifFilterGroupId = v4();
const branches: StepIfElseBranch[] = [
{
id: v4(),
filterGroupId: ifFilterGroupId,
nextStepIds: [ifEmptyNode.id],
},
{
id: v4(),
nextStepIds: [elseEmptyNode.id],
},
];
return {
ifEmptyNode,
elseEmptyNode,
ifFilterGroupId,
branches,
};
},
);
}
async createDraftStep({
step,
workspaceId,
@@ -12,6 +12,7 @@ import { DelayWorkflowAction } from 'src/modules/workflow/workflow-executor/work
import { EmptyWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty.workflow-action';
import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action';
import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action';
import { IfElseWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/if-else.workflow-action';
import { IteratorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action';
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
@@ -32,6 +33,7 @@ export class WorkflowActionFactory {
private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction,
private readonly formWorkflowAction: FormWorkflowAction,
private readonly filterWorkflowAction: FilterWorkflowAction,
private readonly ifElseWorkflowAction: IfElseWorkflowAction,
private readonly iteratorWorkflowAction: IteratorWorkflowAction,
private readonly toolExecutorWorkflowAction: ToolExecutorWorkflowAction,
private readonly aiAgentWorkflowAction: AiAgentWorkflowAction,
@@ -59,6 +61,8 @@ export class WorkflowActionFactory {
return this.formWorkflowAction;
case WorkflowActionType.FILTER:
return this.filterWorkflowAction;
case WorkflowActionType.IF_ELSE:
return this.ifElseWorkflowAction;
case WorkflowActionType.ITERATOR:
return this.iteratorWorkflowAction;
case WorkflowActionType.HTTP_REQUEST:
@@ -0,0 +1,9 @@
import {
type WorkflowAction,
WorkflowActionType,
type WorkflowIfElseAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowIfElseAction = (
action: WorkflowAction,
): action is WorkflowIfElseAction => action.type === WorkflowActionType.IF_ELSE;
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { IfElseWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/if-else.workflow-action';
@Module({
providers: [IfElseWorkflowAction],
exports: [IfElseWorkflowAction],
})
export class IfElseActionModule {}
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { isWorkflowIfElseAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard';
import { findMatchingBranch } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/utils/find-matching-branch.util';
@Injectable()
export class IfElseWorkflowAction implements WorkflowAction {
async execute(input: WorkflowActionInput): Promise<WorkflowActionOutput> {
const { currentStepId, steps, context } = input;
const step = findStepOrThrow({
stepId: currentStepId,
steps,
});
if (!isWorkflowIfElseAction(step)) {
throw new WorkflowStepExecutorException(
'Step is not an if-else action',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const { stepFilterGroups, stepFilters, branches } = step.settings.input;
if (!branches || branches.length === 0) {
throw new WorkflowStepExecutorException(
'If-else action must have at least one branch',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
if (!stepFilterGroups || !stepFilters) {
throw new WorkflowStepExecutorException(
'If-else action must have stepFilterGroups and stepFilters defined',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const resolvedFilters = stepFilters.map((filter) => ({
...filter,
rightOperand: resolveInput(filter.value, context),
leftOperand: resolveInput(filter.stepOutputKey, context),
}));
const matchingBranch = findMatchingBranch({
branches,
stepFilterGroups,
resolvedFilters,
});
return {
result: {
matchingBranchId: matchingBranch.id,
},
};
}
}
@@ -0,0 +1,12 @@
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
import { type StepIfElseBranch } from 'twenty-shared/workflow';
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export type WorkflowIfElseActionSettings = BaseWorkflowActionSettings & {
input: {
stepFilterGroups: StepFilterGroup[];
stepFilters: StepFilter[];
branches: StepIfElseBranch[];
};
};
@@ -0,0 +1,3 @@
export type WorkflowIfElseResult = {
matchingBranchId: string;
};
@@ -0,0 +1,77 @@
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type StepIfElseBranch } from 'twenty-shared/workflow';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
export type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
const collectAllDescendantGroups = (
rootGroupId: string,
allGroups: StepFilterGroup[],
collectedGroups: Set<StepFilterGroup> = new Set(),
): Set<StepFilterGroup> => {
const rootGroup = allGroups.find((group) => group.id === rootGroupId);
if (!rootGroup) {
return collectedGroups;
}
collectedGroups.add(rootGroup);
const childGroups = allGroups.filter(
(group) => group.parentStepFilterGroupId === rootGroupId,
);
for (const childGroup of childGroups) {
collectAllDescendantGroups(childGroup.id, allGroups, collectedGroups);
}
return collectedGroups;
};
export const findMatchingBranch = ({
branches,
stepFilterGroups,
resolvedFilters,
}: {
branches: StepIfElseBranch[];
stepFilterGroups: StepFilterGroup[];
resolvedFilters: ResolvedFilter[];
}): StepIfElseBranch => {
const matchingBranch = branches.find((branch) => {
if (!isDefined(branch.filterGroupId)) {
return true;
}
const branchFilterGroups = Array.from(
collectAllDescendantGroups(branch.filterGroupId, stepFilterGroups),
);
const branchFilterGroupIds = new Set(branchFilterGroups.map((g) => g.id));
const branchFilters = resolvedFilters.filter((filter) =>
branchFilterGroupIds.has(filter.stepFilterGroupId),
);
return evaluateFilterConditions({
filterGroups: branchFilterGroups,
filters: branchFilters,
});
});
if (!isDefined(matchingBranch)) {
throw new WorkflowStepExecutorException(
'No matching branch found in if-else action',
WorkflowStepExecutorExceptionCode.INTERNAL_ERROR,
);
}
return matchingBranch;
};
@@ -5,6 +5,7 @@ import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-
import { type WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { type WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import { type WorkflowHttpRequestActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type';
import { type WorkflowIfElseActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-action-settings.type';
import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
import { type WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type';
import {
@@ -37,6 +38,7 @@ export type WorkflowActionSettings =
| WorkflowFindRecordsActionSettings
| WorkflowFormActionSettings
| WorkflowFilterActionSettings
| WorkflowIfElseActionSettings
| WorkflowHttpRequestActionSettings
| WorkflowAiAgentActionSettings
| WorkflowDelayActionSettings
@@ -8,6 +8,7 @@ export enum WorkflowActionType {
FIND_RECORDS = 'FIND_RECORDS',
FORM = 'FORM',
FILTER = 'FILTER',
IF_ELSE = 'IF_ELSE',
HTTP_REQUEST = 'HTTP_REQUEST',
AI_AGENT = 'AI_AGENT',
ITERATOR = 'ITERATOR',
@@ -4,6 +4,7 @@ import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-
import { type WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { type WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
import { type WorkflowHttpRequestActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type';
import { type WorkflowIfElseActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-action-settings.type';
import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
import { type WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type';
import {
@@ -79,6 +80,11 @@ export type WorkflowFilterAction = BaseWorkflowAction & {
settings: WorkflowFilterActionSettings;
};
export type WorkflowIfElseAction = BaseWorkflowAction & {
type: WorkflowActionType.IF_ELSE;
settings: WorkflowIfElseActionSettings;
};
export type WorkflowHttpRequestAction = BaseWorkflowAction & {
type: WorkflowActionType.HTTP_REQUEST;
settings: WorkflowHttpRequestActionSettings;
@@ -113,6 +119,7 @@ export type WorkflowAction =
| WorkflowFindRecordsAction
| WorkflowFormAction
| WorkflowFilterAction
| WorkflowIfElseAction
| WorkflowHttpRequestAction
| WorkflowAiAgentAction
| WorkflowIteratorAction
@@ -11,6 +11,7 @@ import { DelayActionModule } from 'src/modules/workflow/workflow-executor/workfl
import { EmptyActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/empty-action.module';
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module';
import { IfElseActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/if-else-action.module';
import { IteratorActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator-action.module';
import { RecordCRUDActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/record-crud-action.module';
import { ToolExecutorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action';
@@ -27,6 +28,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
FormActionModule,
BillingModule,
FilterActionModule,
IfElseActionModule,
IteratorActionModule,
AiAgentActionModule,
EmptyActionModule,
@@ -31,6 +31,8 @@ import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/
import { shouldSkipStepExecution } from 'src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util';
import { workflowShouldFail } from 'src/modules/workflow/workflow-executor/utils/workflow-should-fail.util';
import { workflowShouldKeepRunning } from 'src/modules/workflow/workflow-executor/utils/workflow-should-keep-running.util';
import { isWorkflowIfElseAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard';
import { type WorkflowIfElseResult } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-result.type';
import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard';
import { WorkflowIteratorResult } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-result.type';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@@ -204,6 +206,20 @@ export class WorkflowExecutorWorkspaceService {
}
}
if (isWorkflowIfElseAction(executedStep)) {
const ifElseResult = executedStepResult.result as
| WorkflowIfElseResult
| undefined;
if (ifElseResult?.matchingBranchId) {
const matchingBranch = executedStep.settings.input.branches.find(
(branch) => branch.id === ifElseResult.matchingBranchId,
);
return matchingBranch?.nextStepIds;
}
}
return executedStep.nextStepIds;
}