Merge pull request #310 from mdwt/feature/multi-branch-conditions

This commit is contained in:
Dries Augustyns
2026-03-14 16:21:25 +01:00
committed by GitHub
5 changed files with 882 additions and 216 deletions
@@ -727,7 +727,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution,
config: StepConfig,
): Promise<StepResult> {
const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config);
const parsed = WorkflowStepConfigSchemas.condition.parse(config);
// Get the value to evaluate
const contact = execution.contact;
@@ -743,7 +743,7 @@ export class WorkflowExecutionService {
// - data.firstName, data.lastName, etc.
// - workflow.* (execution context - alias for event data)
// - event.* (event data that triggered the workflow)
const actualValue = this.resolveField(field, {
const fieldData = {
contact: {
email: contact.email,
subscribed: contact.subscribed,
@@ -751,9 +751,39 @@ export class WorkflowExecutionService {
data: contactData,
workflow: context,
event: context, // Alias for easier access to event data
});
};
// Evaluate the condition
// Multi-branch mode (switch/case)
if ('mode' in parsed && parsed.mode === 'multi') {
const actualValue = this.resolveField(parsed.field, fieldData);
for (const branch of parsed.branches) {
if (this.evaluateCondition(actualValue, branch.operator, branch.value)) {
return {
field: parsed.field,
mode: 'multi',
matchedBranch: branch.name,
actualValue,
branch: branch.id,
};
}
}
// No branch matched — use default
return {
field: parsed.field,
mode: 'multi',
matchedBranch: 'default',
actualValue,
branch: 'default',
};
}
// Legacy binary mode (if/else)
const field = parsed.field;
const operator = 'operator' in parsed ? parsed.operator : 'equals';
const value = 'value' in parsed ? parsed.value : undefined;
const actualValue = this.resolveField(field, fieldData);
const result = this.evaluateCondition(actualValue, operator, value);
return {
@@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
expect(branch).toBe('yes');
});
});
// ========================================
// MULTI-BRANCH CONDITIONS (switch/case)
// ========================================
describe('Multi-Branch Conditions', () => {
/**
* Helper to create a workflow with a multi-branch condition step
* Creates one exit step per branch + one for default
*/
async function createMultiBranchWorkflow(
contactData: Record<string, unknown>,
conditionConfig: Record<string, unknown>,
) {
const contact = await factories.createContact({
projectId,
data: contactData,
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const conditionStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Multi Condition',
position: {x: 100, y: 0},
config: conditionConfig,
},
});
// Create exit steps for each branch + default
const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>;
const branchExits: Record<string, string> = {};
for (let i = 0; i < branches.length; i++) {
const branch = branches[i]!;
const exitStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: `${branch.name} Path`,
position: {x: 200, y: i * 100},
config: {reason: branch.name},
},
});
branchExits[branch.id] = exitStep.id;
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: exitStep.id,
condition: {branch: branch.id},
priority: i,
},
});
}
// Create default exit
const defaultExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Default Path',
position: {x: 200, y: branches.length * 100},
config: {reason: 'default'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: defaultExit.id,
condition: {branch: 'default'},
priority: branches.length,
},
});
// Connect trigger to condition
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id};
}
const planBranches = {
mode: 'multi' as const,
field: 'data.plan',
branches: [
{id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'},
{id: 'br-free', name: 'Free', operator: 'equals', value: 'free'},
{id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'},
],
};
it('should match the first matching branch', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{plan: 'premium'},
planBranches,
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('br-premium');
});
it('should match a non-first branch correctly', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{plan: 'enterprise'},
planBranches,
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('br-enterprise');
});
it('should fall through to default when no branch matches', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{plan: 'starter'},
planBranches,
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('default');
});
it('should fall through to default when field is missing', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{other: 'value'},
planBranches,
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('default');
});
it('should support mixed operators across branches', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{score: 85},
{
mode: 'multi',
field: 'data.score',
branches: [
{id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90},
{id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70},
{id: 'br-low', name: 'Low', operator: 'lessThan', value: 70},
],
},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
// 85 >= 90 is false, 85 >= 70 is true → should match "Medium"
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('br-medium');
});
it('should respect branch evaluation order (first match wins)', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{plan: 'premium'},
{
mode: 'multi',
field: 'data.plan',
branches: [
{id: 'br-first', name: 'First', operator: 'contains', value: 'prem'},
{id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'},
],
},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
// Both match, but first one should win
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('br-first');
});
it('should support exists/notExists operators in branches', async () => {
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
{plan: 'premium'},
{
mode: 'multi',
field: 'data.plan',
branches: [
{id: 'br-has-plan', name: 'Has Plan', operator: 'exists'},
],
},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('br-has-plan');
});
it('should route to correct exit step via transitions', async () => {
const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow(
{plan: 'free'},
planBranches,
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
// Check the execution completed and ended at the correct exit step
const completedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
// Workflow completed after reaching exit step
expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status);
// Verify it went through the "free" branch exit, not default
const exitStepExecution = await prisma.workflowStepExecution.findFirst({
where: {
executionId: execution.id,
stepId: branchExits['br-free'],
},
});
expect(exitStepExecution).not.toBeNull();
// Verify it did NOT go through the default exit
const defaultStepExecution = await prisma.workflowStepExecution.findFirst({
where: {
executionId: execution.id,
stepId: defaultExitId,
},
});
expect(defaultStepExecution).toBeNull();
});
});
});