Skip to content

Simple Approval Workflow

A complete example of a single-level approval workflow.

Overview

This workflow demonstrates:

  • Validation task
  • User task for approval decision
  • Conditional routing based on decision
  • Post-processing for approved/rejected states

Workflow Diagram

Complete Implementation

Workflow Definition

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

final simpleApprovalWorkflow = WorkflowBuilder(
  'SIMPLE-APPROVAL',
  'Simple Approval Workflow',
  description: 'Single-level approval with approve/reject decision',
)
    .start('begin')

    // Step 1: Validate the request
    .task('validateRequest',
      execute: (ctx) async {
        // Use getInitial<T> for original workflow input
        final entityId = ctx.getInitial<String>('entityId');
        final entityType = ctx.getInitial<String>('entityType');
        final submittedBy = ctx.getInitial<String>('submittedBy');

        // Validation - throw to fail the task
        if (entityId == null || entityType == null || submittedBy == null) {
          throw ArgumentError('Missing required fields: entityId, entityType, submittedBy');
        }

        // Fetch entity details (simulated)
        // In real code: final entity = await entityService.getById(entityId);

        // Return Map<String, dynamic> - builder wraps in TaskSuccess
        return {
          'entityId': entityId,
          'entityType': entityType,
          'entityName': 'Sample Entity', // entity.name
          'entityDetails': 'Entity details here', // entity.summary
          'submittedBy': submittedBy,
          'submittedAt': DateTime.now().toIso8601String(),
        };
      },
    )

    // Step 2: Wait for approval decision
    .userTask('approvalDecision',
      signal: 'approval_decision',
      schemaType: 'userTask.approval',
      title: 'Approve {{entityType}}: {{entityName}}',
      description: 'Please review this {{entityType}} and make an approval decision.',
      assignToRole: 'approvers',
      storeAs: 'approvalResult',
    )

    // Step 3: Route based on decision
    .oneOf('routeDecision', [
      Branch.whenFn(
        (output) => output['approvalResult']?['decision'] == 'approved',
        then: 'processApproval',
      ),
      Branch.whenFn(
        (output) => output['approvalResult']?['decision'] == 'rejected',
        then: 'processRejection',
      ),
      Branch.otherwise(then: 'handleUnknown'),
    ])

    // Step 4a: Process approved
    .task('processApproval',
      execute: (ctx) async {
        // Use getAny<T> for accumulated output from earlier nodes
        final entityId = ctx.getAny<String>('entityId')!;
        // Use get<T> for previous node output (the gateway passes through)
        final approvedBy = ctx.get<String>('approvalResult.completedBy')!;
        final comments = ctx.get<String>('approvalResult.comments');

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

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

        // Return Map - builder wraps in TaskSuccess
        return {
          'approvedBy': approvedBy,
          'approvedAt': DateTime.now().toIso8601String(),
          'comments': comments,
        };
      },
    )

    // Step 4b: Process rejected
    .task('processRejection',
      execute: (ctx) async {
        // Use getAny<T> for accumulated output from earlier nodes
        final entityId = ctx.getAny<String>('entityId')!;
        // Use get<T> for previous node output (the gateway passes through)
        final rejectedBy = ctx.get<String>('approvalResult.completedBy')!;
        final reason = ctx.get<String>('approvalResult.reason');

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

        // Return Map - builder wraps in TaskSuccess
        return {
          'rejectedBy': rejectedBy,
          'rejectedAt': DateTime.now().toIso8601String(),
          'reason': reason,
        };
      },
    )

    // Step 4c: Handle unknown decision
    .task('handleUnknown',
      execute: (ctx) async {
        final decision = ctx.get<String>('approvalResult.decision');
        // Throw to fail the task - builder wraps in TaskFailure
        throw StateError('Unknown decision: $decision');
      },
    )

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

    // Define flow edges
    .connect('begin', 'validateRequest')
    .connect('validateRequest', 'approvalDecision')
    .connect('approvalDecision', 'routeDecision')
    .connect('processApproval', 'approved')
    .connect('processRejection', 'rejected')
    .connect('handleUnknown', 'error')

    .build();

Task Executor (Alternative)

Instead of inline execute, use a registered task executor:

dart
class ApprovalProcessingExecutor extends TaskExecutor {
  static const _schemaType = 'task.approval.process';

  static final typeDescriptor = TypeDescriptor<TaskExecutor>(
    schemaType: _schemaType,
    title: 'Approval Processing Executor',
    fromJson: (_) => ApprovalProcessingExecutor(),
  );

  @override
  String get schemaType => _schemaType;

  @override
  String get name => 'Approval Processing';

  @override
  Future<TaskResult> execute(ExecutionContext context) async {
    // Use getAny<T> for data from earlier nodes
    final entityId = context.getAny<String>('entityId')!;
    // Use get<T> for previous node output
    final result = context.get<Map<String, dynamic>>('approvalResult')!;
    final decision = result['decision'] as String;

    if (decision == 'approved') {
      await processApproval(entityId, result);
      return TaskSuccess(output: {'processed': 'approved'});
    } else if (decision == 'rejected') {
      await processRejection(entityId, result);
      return TaskSuccess(output: {'processed': 'rejected'});
    } else {
      return TaskFailure.validation('Unknown decision: $decision');
    }
  }
}

User Task Executor

User task executors always return WaitForUserTaskResult because they must wait for human interaction:

dart
class ApprovalUserTaskExecutor extends UserTaskExecutor {
  static const _schemaType = 'userTask.approval';

  static final typeDescriptor = TypeDescriptor<UserTaskExecutor>(
    schemaType: _schemaType,
    title: 'Approval Task',
    fromJson: (_) => ApprovalUserTaskExecutor(),
  );

  @override
  String get schemaType => _schemaType;

  @override
  String get name => 'Approval Task';

  @override
  Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
    // Use get<T> for previous node output
    final entityType = context.get<String>('entityType') ?? 'Item';
    final entityName = context.get<String>('entityName') ?? 'Unknown';
    final roleId = context.get<String>('approverRoleId') ?? 'approvers';

    return WaitForUserTaskResult(
      signalName: context.signalName!,
      config: UserTaskConfiguration(
        title: 'Approve $entityType: $entityName',
        description: 'Please review and approve this ${entityType.toLowerCase()}',
        schemaType: schemaType,
        assignedToRoleId: roleId,
        priority: UserTaskPriority.high,
        input: context.input,
        storeAs: 'approvalDecision',
      ),
    );
  }

  @override
  Map<String, dynamic> processOutput(Map<String, dynamic> userOutput) {
    final decision = userOutput['decision'];
    if (decision != 'approved' && decision != 'rejected') {
      throw ArgumentError('Decision must be "approved" or "rejected"');
    }
    return userOutput;
  }
}

Starting the Workflow

dart
// Start workflow
final instance = await engine.startWorkflow(
  workflowCode: simpleApprovalWorkflow.code,  // Use workflowCode for semantic lookup
  input: {
    'entityId': 'DOC-12345',
    'entityType': 'Document',
    'submittedBy': 'user@example.com',
  },
);

print('Workflow started: ${instance.id}');
print('Status: ${instance.status}'); // WaitingForSignal

Completing the User Task

dart
// Get pending tasks for approver via storage
final tasks = await engine.storage.userTaskInstances.query(
  assignedToRoleId: 'approvers',
  status: TaskStatus.pending,
);

// Complete the task with approval output
final task = tasks.first;
final completedTask = task.completeWithOutput(
  'approver@example.com',
  {'decision': 'approved', 'comments': 'Looks good, approved!'},
);

// Update in storage
await engine.storage.userTaskInstances.update(completedTask);

// Signal the workflow to continue
await engine.sendSignal(
  workflowInstanceId: task.workflowInstanceId,
  node: task.nodeId,
  payload: completedTask.output,
);

Output Schema

Final workflow output after approval:

json
{
  "entityId": "DOC-12345",
  "entityType": "Document",
  "entityName": "Q4 Report",
  "entityDetails": "Quarterly financial report...",
  "submittedBy": "user@example.com",
  "submittedAt": "2024-01-15T10:30:00Z",
  "approvalResult": {
    "decision": "approved",
    "completedBy": "approver@example.com",
    "completedAt": "2024-01-15T14:22:00Z",
    "comments": "Looks good, approved!"
  },
  "approvedBy": "approver@example.com",
  "approvedAt": "2024-01-15T14:22:01Z",
  "comments": "Looks good, approved!"
}

Testing

dart
test('workflow approves entity on approval decision', () async {
  // Start workflow
  final instance = await engine.startWorkflow(
    workflowCode: 'SIMPLE-APPROVAL',
    input: {
      'entityId': 'TEST-001',
      'entityType': 'TestEntity',
      'submittedBy': 'submitter@test.com',
    },
  );

  // Verify waiting for user task
  expect(instance.status, WorkflowStatus.waitingForSignal);

  // Get user task via storage
  final tasks = await engine.storage.userTaskInstances.query(
    workflowInstanceId: instance.id,
    status: TaskStatus.pending,
  );
  expect(tasks.length, 1);
  expect(tasks.first.schemaType, 'userTask.approval');

  // Complete via signal
  await engine.sendSignal(
    workflowInstanceId: instance.id,
    node: tasks.first.nodeId,
    payload: {
      'decision': 'approved',
      'completedBy': 'approver@test.com',
    },
  );

  // Verify completion
  final completed = await engine.getWorkflowInstance(instance.id);
  expect(completed!.status, WorkflowStatus.completed);
  expect(completed.output['approvalResult']['decision'], 'approved');
});

Next Steps