Skip to content

Multi-Level Approval Workflow

A complete example of a dynamic approval chain workflow.

Overview

This workflow demonstrates:

  • Dynamic approval chain building based on business rules
  • Loop pattern for sequential approvals
  • Early exit on rejection
  • Escalation to next level on approval

Workflow Diagram

Complete Implementation

dart
import 'package:vyuh_workflow_engine/vyuh_workflow_engine.dart';

final multiLevelApprovalWorkflow = WorkflowBuilder(
  'MLA',
  'Multi-Level Approval',
  description: 'Dynamic approval chain based on amount thresholds',
)
    .start('begin')

    // Step 1: Build approval chain based on business rules
    .task('buildApprovalChain',
      execute: (ctx) async {
        // Use getInitial<T> for original workflow input
        final amount = ctx.getInitialRequired<num>('amount').toDouble();
        final category = ctx.getInitialRequired<String>('category');
        final entityId = ctx.getInitialRequired<String>('entityId');
        final submittedBy = ctx.getInitialRequired<String>('submittedBy');

        // Fetch entity details
        final entity = await entityService.getById(entityId);

        // Build approval chain based on amount thresholds
        List<Map<String, dynamic>> chain = [];

        // Level 1: Supervisor (always required)
        chain.add({
          'level': 1,
          'role': 'supervisor',
          'name': 'Supervisor',
          'threshold': 0,
        });

        // Level 2: Manager (> $5,000)
        if (amount > 5000) {
          chain.add({
            'level': 2,
            'role': 'manager',
            'name': 'Manager',
            'threshold': 5000,
          });
        }

        // Level 3: Director (> $25,000)
        if (amount > 25000) {
          chain.add({
            'level': 3,
            'role': 'director',
            'name': 'Director',
            'threshold': 25000,
          });
        }

        // Level 4: VP (> $100,000)
        if (amount > 100000) {
          chain.add({
            'level': 4,
            'role': 'vp',
            'name': 'Vice President',
            'threshold': 100000,
          });
        }

        // Level 5: CFO (> $500,000)
        if (amount > 500000) {
          chain.add({
            'level': 5,
            'role': 'cfo',
            'name': 'CFO',
            'threshold': 500000,
          });
        }

        // Return Map - builder wraps in TaskSuccess
        return {
          'entityId': entityId,
          'entityName': 'Sample Entity', // entity.name
          'amount': amount,
          'category': category,
          'submittedBy': submittedBy,
          'submittedAt': DateTime.now().toIso8601String(),
          'approvalChain': chain,
          'currentLevelIndex': 0,
          'totalLevels': chain.length,
          'hasMoreLevels': chain.isNotEmpty,
          'approvalHistory': <Map<String, dynamic>>[],
        };
      },
    )

    // Step 2: Gateway - Check if more levels to process
    .oneOf('checkHasMoreLevels', [
      Branch.whenFn(
        (o) => o['hasMoreLevels'] == true,
        then: 'startLevel',
      ),
      Branch.otherwise(then: 'makeEffective'),
    ])

    // Step 3: Prepare current approval level
    .task('startLevel',
      execute: (ctx) async {
        // Use getAny<T> for accumulated output (chain built earlier, index updated in loop)
        final chain = ctx.getAny<List>('approvalChain')!;
        final levelIndex = ctx.getAny<int>('currentLevelIndex')!;
        final currentLevel = chain[levelIndex] as Map<String, dynamic>;

        return {
          'currentLevel': currentLevel,
          'currentLevelNumber': currentLevel['level'],
          'currentLevelName': currentLevel['name'],
          'currentLevelRole': currentLevel['role'],
          'levelStartedAt': DateTime.now().toIso8601String(),
        };
      },
    )

    // Step 4: User task for current level approval
    .userTask('levelApproval',
      signal: 'level_approval_decision',
      schemaType: 'userTask.approval',
      title: 'Level {{currentLevelNumber}} Approval: {{entityName}}',
      description: 'Please review this request for {{entityName}}. Amount: \${{amount}}. This is level {{currentLevelNumber}} of {{totalLevels}}.',
      assignToRole: '{{currentLevelRole}}',
      storeAs: 'levelDecision',
    )

    // Step 5: Route based on level decision
    .oneOf('routeLevelDecision', [
      Branch.whenFn(
        (o) => o['levelDecision']?['decision'] == 'approved',
        then: 'recordApprovalAndIncrement',
      ),
      Branch.whenFn(
        (o) => o['levelDecision']?['decision'] == 'rejected',
        then: 'handleRejection',
      ),
      Branch.whenFn(
        (o) => o['levelDecision']?['decision'] == 'revision',
        then: 'requestRevision',
      ),
      Branch.otherwise(then: 'handleRejection'),
    ])

    // Step 6a: Record approval and increment level
    .task('recordApprovalAndIncrement',
      execute: (ctx) async {
        // Use getAny<T> for accumulated output (from loop iterations)
        final currentIndex = ctx.getAny<int>('currentLevelIndex')!;
        final totalLevels = ctx.getAny<int>('totalLevels')!;
        // Use get<T> for previous node output (user task result)
        final decision = ctx.get<Map<String, dynamic>>('levelDecision')!;
        final history = List<Map<String, dynamic>>.from(
          ctx.getAny<List>('approvalHistory') ?? [],
        );

        // Record this approval
        history.add({
          'level': ctx.getAny<int>('currentLevelNumber'),
          'levelName': ctx.getAny<String>('currentLevelName'),
          'decision': 'approved',
          'approvedBy': decision['completedBy'],
          'approvedAt': DateTime.now().toIso8601String(),
          'comments': decision['comments'],
        });

        // Calculate next level
        final nextIndex = currentIndex + 1;
        final hasMore = nextIndex < totalLevels;

        return {
          'currentLevelIndex': nextIndex,
          'hasMoreLevels': hasMore,
          'approvalHistory': history,
        };
      },
    )

    // Step 6b: Handle rejection
    .task('handleRejection',
      execute: (ctx) async {
        // Use get<T> for previous node output (user task result)
        final decision = ctx.get<Map<String, dynamic>>('levelDecision')!;
        final history = List<Map<String, dynamic>>.from(
          ctx.getAny<List>('approvalHistory') ?? [],
        );

        // Record rejection
        history.add({
          'level': ctx.getAny<int>('currentLevelNumber'),
          'levelName': ctx.getAny<String>('currentLevelName'),
          'decision': 'rejected',
          'rejectedBy': decision['completedBy'],
          'rejectedAt': DateTime.now().toIso8601String(),
          'reason': decision['reason'],
        });

        // Update entity status (simulated)
        // await entityService.updateStatus(entityId, 'rejected');

        return {
          'rejectedBy': decision['completedBy'],
          'rejectedAt': DateTime.now().toIso8601String(),
          'rejectedAtLevel': ctx.getAny<int>('currentLevelNumber'),
          'reason': decision['reason'],
          'approvalHistory': history,
        };
      },
    )

    // Step 6c: Request revision
    .task('requestRevision',
      execute: (ctx) async {
        // Use get<T> for previous node output (user task result)
        final decision = ctx.get<Map<String, dynamic>>('levelDecision')!;

        // Send notification (simulated)
        // await notificationService.send(...);

        return {
          'revisionRequested': true,
          'requestedBy': decision['completedBy'],
          'feedback': decision['feedback'],
        };
      },
    )

    // Step 6c continued: Wait for submitter to revise
    .userTask('revisionTask',
      signal: 'revision_submitted',
      schemaType: 'userTask.revision',
      title: 'Revision Required: {{entityName}}',
      description: 'Revision has been requested for {{entityName}}. Please make the necessary changes and resubmit.',
      assignToUser: '{{submittedBy}}',
      storeAs: 'revisionResult',
    )

    // After revision, restart from first level
    .task('resetAfterRevision',
      execute: (ctx) async {
        // Use getAny<T> for accumulated output
        final revisionCount = ctx.getAny<int>('revisionCount') ?? 0;
        return {
          'currentLevelIndex': 0,
          'hasMoreLevels': true,
          'approvalHistory': <Map<String, dynamic>>[],
          'revisionCount': revisionCount + 1,
        };
      },
    )

    // Step 7: Make effective after all approvals
    .task('makeEffective',
      execute: (ctx) async {
        // Update entity status (simulated)
        // await entityService.updateStatus(entityId, 'effective');

        return {
          'effectiveAt': DateTime.now().toIso8601String(),
          'finalStatus': 'effective',
        };
      },
    )

    // End nodes
    .end('approved', name: 'Approved')
    .end('rejected', name: 'Rejected')

    // Flow edges
    .connect('begin', 'buildApprovalChain')
    .connect('buildApprovalChain', 'checkHasMoreLevels')
    .connect('startLevel', 'levelApproval')
    .connect('levelApproval', 'routeLevelDecision')
    .connect('recordApprovalAndIncrement', 'checkHasMoreLevels')  // Loop back
    .connect('handleRejection', 'rejected')
    .connect('requestRevision', 'revisionTask')
    .connect('revisionTask', 'resetAfterRevision')
    .connect('resetAfterRevision', 'checkHasMoreLevels')  // Restart approval
    .connect('makeEffective', 'approved')

    .build();

Approval Chain Configuration

Customize the approval chain logic:

dart
class ApprovalChainBuilder {
  static List<Map<String, dynamic>> buildChain({
    required double amount,
    required String category,
    required String department,
  }) {
    final chain = <Map<String, dynamic>>[];

    // Base configuration
    final thresholds = _getThresholds(category, department);

    for (final threshold in thresholds) {
      if (amount > threshold['minAmount']) {
        chain.add({
          'level': chain.length + 1,
          'role': threshold['role'],
          'name': threshold['name'],
          'threshold': threshold['minAmount'],
        });
      }
    }

    return chain;
  }

  static List<Map<String, dynamic>> _getThresholds(
    String category,
    String department,
  ) {
    // Different thresholds per category
    if (category == 'CAPITAL_EXPENSE') {
      return [
        {'minAmount': 0, 'role': 'supervisor', 'name': 'Supervisor'},
        {'minAmount': 10000, 'role': 'manager', 'name': 'Manager'},
        {'minAmount': 50000, 'role': 'director', 'name': 'Director'},
        {'minAmount': 200000, 'role': 'vp', 'name': 'VP'},
        {'minAmount': 1000000, 'role': 'cfo', 'name': 'CFO'},
      ];
    } else {
      return [
        {'minAmount': 0, 'role': 'supervisor', 'name': 'Supervisor'},
        {'minAmount': 5000, 'role': 'manager', 'name': 'Manager'},
        {'minAmount': 25000, 'role': 'director', 'name': 'Director'},
      ];
    }
  }
}

Tracking Approval Progress

dart
// Query workflow for approval status
final instance = await engine.getWorkflowInstance(instanceId);
final output = instance.output;

print('Entity: ${output['entityName']}');
print('Amount: \$${output['amount']}');
print('Current Level: ${output['currentLevelIndex'] + 1} of ${output['totalLevels']}');
print('Approval History:');

for (final approval in output['approvalHistory']) {
  print('  Level ${approval['level']} (${approval['levelName']}): '
      '${approval['decision']} by ${approval['approvedBy'] ?? approval['rejectedBy']}');
}

Testing

dart
group('Multi-Level Approval', () {
  test('small amount requires only supervisor approval', () async {
    final instance = await engine.startWorkflow(
      workflowId: 'multi-level-approval',
      input: {
        'entityId': 'PO-001',
        'amount': 1000,  // Below $5,000
        'category': 'OFFICE_SUPPLIES',
        'submittedBy': 'employee@company.com',
      },
    );

    // Only 1 level in chain
    expect(instance.output['totalLevels'], 1);

    // Complete supervisor approval
    await completeApproval(instance.id, 'supervisor', 'approved');

    final completed = await engine.getWorkflowInstance(instance.id);
    expect(completed.status, WorkflowStatus.completed);
    expect(completed.output['finalStatus'], 'effective');
  });

  test('large amount requires multiple approvals', () async {
    final instance = await engine.startWorkflow(
      workflowId: 'multi-level-approval',
      input: {
        'entityId': 'PO-002',
        'amount': 150000,  // > $100,000
        'category': 'EQUIPMENT',
        'submittedBy': 'employee@company.com',
      },
    );

    // 4 levels required: supervisor, manager, director, VP
    expect(instance.output['totalLevels'], 4);

    // Complete each level
    await completeApproval(instance.id, 'supervisor', 'approved');
    await completeApproval(instance.id, 'manager', 'approved');
    await completeApproval(instance.id, 'director', 'approved');
    await completeApproval(instance.id, 'vp', 'approved');

    final completed = await engine.getWorkflowInstance(instance.id);
    expect(completed.status, WorkflowStatus.completed);
    expect(completed.output['approvalHistory'].length, 4);
  });

  test('rejection at any level stops workflow', () async {
    final instance = await engine.startWorkflow(
      workflowId: 'multi-level-approval',
      input: {
        'entityId': 'PO-003',
        'amount': 30000,
        'category': 'EQUIPMENT',
        'submittedBy': 'employee@company.com',
      },
    );

    // Approve first level
    await completeApproval(instance.id, 'supervisor', 'approved');

    // Reject at second level
    await completeApproval(instance.id, 'manager', 'rejected',
      reason: 'Budget exceeded for this quarter',
    );

    final completed = await engine.getWorkflowInstance(instance.id);
    expect(completed.status, WorkflowStatus.completed);
    expect(completed.output['rejectedAtLevel'], 2);
  });
});

Future<void> completeApproval(
  String instanceId,
  String role,
  String decision, {
  String? reason,
}) async {
  final tasks = await engine.getUserTasks(
    workflowInstanceId: instanceId,
    assignedToRole: role,
    status: UserTaskStatus.pending,
  );

  await engine.completeUserTask(
    taskId: tasks.first.id,
    completedBy: '$role@company.com',
    response: {
      'decision': decision,
      if (reason != null) 'reason': reason,
    },
  );
}

Next Steps