Skip to main content

Parallel approval without branches

Introduction

Sometimes we need to get simultaneous document approval from several people. The document should be approved simultaneously by all parties. Use parallel branches for this. The people with the authority to approve the document, however, frequently change between each stage. To make it possible, you will need to create the scheme with complex parallel branches or use scheme generation. Your project will thereafter get more challenging.

To consistently describe this logic, there is a rather simple solution, though. In this case you won't have to use parallel branches or the generation of the scheme. This solution is based on the ability of the engine to save any objects in process parameters, and pass the parameters to code actions. You can modify and use it in your own solution. There is also an approval plugin that simplifies this task and provides a ready-made template.

GitHub

The full project code is available in the repository.

The project

The project will be implemented piece by piece starting from scratch. Three projects are required, including one class library to work with the Workflow Engine and include some business logic, one Asp.Net MVC project, and one console application to test it all in action.

Project preparation

Let's make a folder, put all the required projects and solutions inside of it, then link them all to one another.

mkdir parallel-approval
cd parallel-approval
dotnet new sln
dotnet new classlib --name WorkflowLib
dotnet new mvc --name WorkflowDesigner
dotnet new console --name WorkflowConsole
dotnet sln add WorkflowLib
dotnet sln add WorkflowDesigner
dotnet sln add WorkflowConsole
dotnet add WorkflowDesigner reference WorkflowLib
dotnet add WorkflowConsole reference WorkflowLib

And add NuGet packages.

dotnet add WorkflowLib package WorkflowEngine.NETCore-Core
dotnet add WorkflowLib package WorkflowEngine.NETCore-ProviderForMSSQL
How To Integrate

You can learn more about integration with Workflow Engine in the article how to integrate.

Setting up the database

Initially we need to create a database. Follow the steps from Setting up the database section of How to Integrate article.

Integrating Workflow Engine

Add WorkflowInit class to WorkflowLib project:

./WorkflowLib/WorkflowInit.cs
using System.Xml.Linq;
using OptimaJet.Workflow.Core.Builder;
using OptimaJet.Workflow.Core.Runtime;
using OptimaJet.Workflow.DbPersistence;
using OptimaJet.Workflow.Plugins.ApproversProvider;

public static class WorkflowInit
{
private static readonly Lazy<WorkflowRuntime> LazyRuntime = new Lazy<WorkflowRuntime>(InitWorkflowRuntime);

public static WorkflowRuntime Runtime
{
get { return LazyRuntime.Value; }
}

public static string ConnectionString { get; set; }

private static WorkflowRuntime InitWorkflowRuntime()
{
if (string.IsNullOrEmpty(ConnectionString))
{
throw new Exception("Please init ConnectionString before calling the Runtime!");
}
// TODO If you have a license key, you have to register it here
//WorkflowRuntime.RegisterLicense("your license key text");

var dbProvider = new MSSQLProvider(ConnectionString);

var builder = new WorkflowBuilder<XElement>(
dbProvider,
new OptimaJet.Workflow.Core.Parser.XmlWorkflowParser(),
dbProvider
).WithDefaultCache();

var runtime = new WorkflowRuntime()
.WithBuilder(builder)
.WithPersistenceProvider(dbProvider)
.EnableCodeActions()
.RegisterAssemblyForCodeActions(typeof(Approvers).Assembly)
.SwitchAutoUpdateSchemeBeforeGetAvailableCommandsOn()
.AsSingleServer();

runtime.Start();

return runtime;
}
}

Connecting the designer

Now we will set up a designer in the WorkflowDesigner project, for this we need to create a DesignerController, its code is presented below. Place the controller in the controllers folder according to the ASP NET Core architecture approach. You can learn more about connecting a designer in the article How to integrate.

./WorkflowDesigner/Controllers/DesignerController.cs
using System.Collections.Specialized;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using OptimaJet.Workflow;

namespace WorkflowDesigner.Controllers;

public class DesignerController : Controller
{
public async Task<IActionResult> Api()
{
Stream? filestream = null;
var parameters = new NameValueCollection();

// Defining the request method
var isPost = Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase);

// Parse the parameters in the query string
foreach (var q in Request.Query)
{
parameters.Add(q.Key, q.Value.First());
}

if (isPost)
{
// Parsing the parameters passed in the form
var keys = parameters.AllKeys;

foreach (var key in Request.Form.Keys)
{
if (!keys.Contains(key))
{
parameters.Add(key, Request.Form[key]);
}
}

// If a file is passed
if (Request.Form.Files.Count > 0)
{
// Save file
filestream = Request.Form.Files[0].OpenReadStream();
}
}

// Calling the Designer Api and store answer
var (result, hasError) = await WorkflowInit.Runtime.DesignerAPIAsync(parameters, filestream);

// If it returns a file, send the response in a special way
if (parameters["operation"]?.ToLower() == "downloadscheme" && !hasError)
return File(Encoding.UTF8.GetBytes(result), "text/xml");

if (parameters["operation"]?.ToLower() == "downloadschemebpmn" && !hasError)
return File(Encoding.UTF8.GetBytes(result), "text/xml");

// response
return Content(result);
}

public IActionResult Index()
{
return View();
}
}

We also need to create a designer directory in the Views folder, where we need the index.cshtml file, its code is presented below.

./WorkflowDesigner/Views/Designer/index.cshtml
@model dynamic

@{
ViewBag.Title = "Designer";
Layout = "_Layout";
}

<link href="~/css/workflowdesigner.min.css" rel="stylesheet" type="text/css"/>
<script src="~/js/workflowdesigner.min.js" type="text/javascript"></script>
<script src="~/lib/jquery/dist/jquery.min.js" type="text/javascript"></script>
<form action="" id="uploadform" method="post" enctype="multipart/form-data" onsubmit="tmp()" style="padding-bottom: 8px;">
<input type="file" name="uploadFile" id="uploadFile" style="display:none" onchange="javascript: UploadScheme(this);"/>
</form>
<div id="wfdesigner" style="min-height:600px; max-width: 1200px;"></div>

<script>
var QueryString = function () {
// This function is anonymous, is executed immediately and
// the return value is assigned to QueryString!
var query_string = {};
var query = window.location.search.substring(1);
var vars = query.split("&");
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split("=");
// If first entry with this name
if (typeof query_string[pair[0]] === "undefined") {
query_string[pair[0]] = pair[1];
// If second entry with this name
} else if (typeof query_string[pair[0]] === "string") {
var arr = [query_string[pair[0]], pair[1]];
query_string[pair[0]] = arr;
// If third or later entry with this name
} else {
query_string[pair[0]].push(pair[1]);
}
}
return query_string;
}();
// Load settings
var schemecode = QueryString.code ? QueryString.code : 'SimpleWF';
var processid = QueryString.processid;
var graphwidth = 1200;
var graphminheight = 600;
var graphheight = graphminheight;
var wfdesigner = undefined;

// Recreate designer object
function wfdesignerRedraw() {
var data;
if (wfdesigner != undefined) {
wfdesigner.destroy();
}
wfdesigner = new WorkflowDesigner({
name: 'simpledesigner',
apiurl: '/Designer/API',
showSaveButton: true,
renderTo: 'wfdesigner',
templatefolder: '/templates/',
graphwidth: graphwidth,
graphheight: graphheight,
});
if (data == undefined) {
var isreadonly = false;
if (processid != undefined && processid != '')
isreadonly = true;
var p = {schemecode: schemecode, processid: processid, readonly: isreadonly};
if (wfdesigner.exists(p))
wfdesigner.load(p);
else
wfdesigner.create(schemecode);
} else {
wfdesigner.data = data;
wfdesigner.render();
}
}

wfdesignerRedraw();
// Adjusts the size of the designer window
$(window).resize(function () {
if (window.wfResizeTimer) {
clearTimeout(window.wfResizeTimer);
window.wfResizeTimer = undefined;
}
window.wfResizeTimer = setTimeout(function () {
var w = $(window).width();
var h = $(window).height();
if (w > 300)
graphwidth = w - 40;
if (h > 300)
graphheight = h - 250;
if (graphheight < graphminheight)
graphheight = graphminheight;
wfdesigner.resize(graphwidth, graphheight);
}, 150);
});
$(window).resize();

function DownloadScheme() {
wfdesigner.downloadscheme();
}

function DownloadSchemeBPMN() {
wfdesigner.downloadschemeBPMN();
}

var selectSchemeType;

function SelectScheme(type) {
if (type)
selectSchemeType = type;
var file = $('#uploadFile');
file.trigger('click');
}

function UploadScheme(form) {
if (form.value == "")
return;
if (selectSchemeType == "bpmn") {
wfdesigner.uploadschemeBPMN($('#uploadform')[0], function () {
wfdesigner.autoarrangement();
alert('The file is uploaded!');
});
} else {
wfdesigner.uploadscheme($('#uploadform')[0], function () {
alert('The file is uploaded!');
});
}
}

function OnSave() {
wfdesigner.schemecode = schemecode;
var err = wfdesigner.validate();
if (err != undefined && err.length > 0) {
alert(err);
} else {
wfdesigner.save(function () {
alert('The scheme is saved!');
});
}
}

function OnNew() {
wfdesigner.create();
}
</script>

Then we need to add interface artifacts for the designer, we need to put them in the /wwwroot directory in the appropriate folders. You can download the artifacts in WorkflowEngine.NET GitHub. The folder templates is required, besides the file: workflowdesigner.min.css and workflowdesigner.min.js (save them in css and js directories respectively).

Now let's set up the connection in our application, to do this, add the connection string to appsettings.json.

Connection string

Don't forget to adjust the connection string to the location of your database.

./WorkflowDesigner/appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Data Source=(local);Initial Catalog=wfe_sample;User ID=sa;Password=1"
}
}

And initialize the line for WorkflowRuntime:

./WorkflowDesigner/Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

WorkflowInit.ConnectionString = app.Configuration.GetConnectionString("DefaultConnection");

app.Run();

Put the button on the home page to get to the designer.

./WorkflowDesigner/Views/Home/index.cshtml
@{
ViewData["Title"] = "Home Page";
}

<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
<p><a href="/Designer/Index">Open designer here</a></p>
</div>

Since the designer is prepared for use, we may move on to parallel approval.

Implementation of logic

The entire statement's logic will fit inside two classes, but you are free to change anything to meet your needs. We need to create these classes in WorkflowLib project.

  • Approvers. This class is used to keep the information about users who can approve or have already approved the document. The class is designed to be serializable into JSON. The code of this class is trivial, so it doesn't require any additional notes.

    ./WorkflowLib/Approvers.cs
    using Newtonsoft.Json;

    namespace WorkflowLib;

    public class Approvers
    {
    // Must be a public property to serialize to JSON
    public Dictionary<string, bool> ApproversDictionary { get; set; }

    public Approvers(List<string> ids)
    {
    // Required for correct deserialization from JSON
    if (ids == null)
    {
    ApproversDictionary = new Dictionary<string, bool>();
    }
    else
    {
    ApproversDictionary = ids.ToDictionary(id => id, id => false);
    }
    }

    [JsonIgnore]
    public bool IsApproved
    {
    get { return ApproversDictionary.Values.All(v => v); }
    }

    public void Approve(string id)
    {
    ApproversDictionary[id] = true;
    }

    public void Reset()
    {
    foreach (var k in ApproversDictionary.Keys)
    {
    ApproversDictionary[k] = false;
    }
    }

    public List<string> GetAvailableApprovers()
    {
    return ApproversDictionary.Where(s => !s.Value).Select(s => s.Key).ToList();
    }
    }
  • ApproversProvider. This is a static class that has only one static method GetApprovers. This method contains the logic that determines approvers at each stage. In our example, this method requires a string parameter, based on which we can define the list of approvers identifiers. This is merely one choice among many; the logic of your application may be entirely different.

    ./WorkflowLib/ApproversProvider.cs
    using OptimaJet.Workflow.Core.Model;

    namespace WorkflowLib;

    public class ApproversProvider
    {
    public static Approvers GetApprovers(ProcessInstance processInstance, string name)
    {
    switch (name)
    {
    case "Stage1":
    return new Approvers(new List<string>{"user1", "user2"});
    case "Stage2":
    return new Approvers(new List<string> {"user3", "user4", "user5"});
    default:
    return new Approvers(new List<string>());
    }
    }
    }

Next, you need to bind the library namespace to our runtime, to do this, in the WorkflowLib.WorkflowInit.cs class, we change the CreateRuntime method as follows:

./WorkflowInit/WorkflowInit.cs
using BusinessApproval;

...

var runtime = new WorkflowRuntime()
.WithBuilder(builder)
.WithPersistenceProvider(dbProvider)
.EnableCodeActions()
.SwitchAutoUpdateSchemeBeforeGetAvailableCommandsOn()
.RegisterAssemblyForCodeActions(typeof(Approvers).Assembly)
.AsSingleServer();

Scheme design

Scheme

First, we will add three commands: approve, start, deny.

Next, create CodeActions. You can learn more about CodeActions in the article Actions, IWorkflowActionProvider and CodeActions.

  • IsApproveComplete type Condition. This condition is used to find out if all users approved the document.

    IsApproveComplete
    var approvers = processInstance.GetParameter<Approvers>("Approvers");
    return approvers.IsApproved;
  • Approve type Action. This Action registers document approval by a user who executes a command.

    Approve
    if (string.IsNullOrEmpty(processInstance.CurrentCommand) ||
    processInstance.CurrentCommand.Equals("start", StringComparison.InvariantCultureIgnoreCase))
    return;

    var approvers = processInstance.GetParameter<Approvers>("Approvers");
    approvers.Approve(processInstance.IdentityId);
    processInstance.SetParameter<Approvers>("Approvers", approvers, ParameterPurpose.Persistence);
  • FillApprovers type Action. This method is called at the beginning of each stage. It gets the Approvers object from ApproversProvider.GetApprovers method, and saves it to process parameters.

    FillApprovers
    processInstance.SetParameter<Approvers>("Approvers",
    ApproversProvider.GetApprovers(processInstance, parameter),
    ParameterPurpose.Persistence);
  • Approver type RuleGet. This rule is used to check whether identityId is registered in the Approvers object and whether the user approved the document before.

    Approver
    var approvers = processInstance.GetParameter<Approvers>("Approvers");
    return approvers.GetAvailableApprovers();

Adding the actor Name: Approver; Rule: Approver;

Let's move on to the implementation of the scheme:

Activities description

ActivityStateInitialFinalFor set stateImplementation actionImplementation parameter
DraftDraft++
Stage1InitStage1+FillApproversStage1
Stage1Approve
Stage2InitStage2+FillApproversStage2
Stage2Approve
FinalFinal++

Transitions description

FromToClassifierTriggerCommandConditionCondition ActionRestriction
DraftStage1InitDirectCommandstartAlways
Stage1InitStage1DirectAutoAlways
Stage1Stage1DirectCommandapproveAlwaysAllow Approver
Stage1DraftReverseCommanddenyAlways
Stage1Stage2InitDirectAutoConditionIsApproveComplete
Stage2InitStage2DirectAutoAlways
Stage2Stage2DirectCommandapproveAlwaysAllow Approver
Stage2Stage1InitReverseCommanddenyAlways
Stage2FinalDirectAutoConditionalIsApproveComplete

Don't forget to save the scheme.

Scheme

The final scheme can be downloaded here.

Creating a console application

Finally, let's create the console application to check. Add the following to WorkflowConsole project:

Connection string

Don't forget to adjust the connection string to the location of your database.

./WorkflowConsole/Program.cs
using OptimaJet.Workflow.Core.Runtime;
using OptimaJet.Workflow.Core.Subprocess;

WorkflowInit.ConnectionString = "Data Source=(local);Initial Catalog=wfe_sample;User ID=sa;Password=1";
string currentUser = string.Empty;
const string schemeCode = "SimpleWF";
Guid? processId;
Main();

void Main()
{
Console.WriteLine("Operation:");
Console.WriteLine("0 - Set current user");
Console.WriteLine("1 - CreateInstance");
Console.WriteLine("2 - GetAvailableCommands");
Console.WriteLine("3 - ExecuteCommand");
Console.WriteLine("4 - GetAvailableState");
Console.WriteLine("5 - SetState");
Console.WriteLine("6 - DeleteProcess");
Console.WriteLine("9 - Exit");

Console.WriteLine("The process is not created.");
CreateInstance();

do
{
if (processId.HasValue)
{
Console.WriteLine("ProcessId = '{0}'. CurrentState: {1}, CurrentActivity: {2}",
processId,
WorkflowInit.Runtime.GetCurrentStateName(processId.Value),
WorkflowInit.Runtime.GetCurrentActivityName(processId.Value));

var processTree = WorkflowInit.Runtime.GetProcessInstancesTree(processId.Value);
if (processTree != null)
{
var current = processTree.Root;
var level = "->";
WriteSubprocesses(current, level);
}
}

if (!string.IsNullOrEmpty(currentUser))
Console.WriteLine("Current user = {0}.", currentUser);
else
Console.WriteLine("Current user is undefined.");

Console.Write("Enter code of operation:");

char operation = (Console.ReadLine() ?? string.Empty).FirstOrDefault();

switch (operation)
{
case '0':
SetUser();
break;
case '1':
CreateInstance();
break;
case '2':
GetAvailableCommands();
break;
case '3':
ExecuteCommand();
break;
case '4':
GetAvailableState();
break;
case '5':
SetState();
break;
case '6':
DeleteProcess();
break;
case '9':
return;
default:
Console.WriteLine("Unknown code. Please, repeat.");
break;
}

Console.WriteLine();
} while (true);
}

void WriteSubprocesses(ProcessInstancesTree current, string level)
{
foreach (var child in current.Children)
{
Console.WriteLine("{0}SubProcessId = '{1}'. CurrentState: {2}, CurrentActivity: {3}", level, child.Id,
WorkflowInit.Runtime.GetCurrentStateName(child.Id), WorkflowInit.Runtime.GetCurrentActivityName(child.Id));

WriteSubprocesses(child, level + "->");
}
}

void SetUser()
{
Console.Write("Enter user's id: ");
var readLine = Console.ReadLine();
if (readLine != null)
{
currentUser = readLine.Trim();
}
}

void CreateInstance()
{
processId = Guid.NewGuid();

try
{
WorkflowInit.Runtime.CreateInstance(schemeCode, processId.Value);
Console.WriteLine("CreateInstance - OK.");
}
catch (Exception ex)
{
Console.WriteLine("CreateInstance - Exception: {0}, {1}", ex.Message,
ex.InnerException != null ? ex.InnerException.Message : string.Empty);
processId = null;
}
}

void GetAvailableCommands()
{
if (processId == null)
{
Console.WriteLine("The process isn't created. Please, create process instance.");
return;
}

var commands = WorkflowInit.Runtime.GetAvailableCommands(processId.Value, currentUser).ToList();

Console.WriteLine("Available commands:");
if (!commands.Any())
{
Console.WriteLine("Not found!");
}
else
{
foreach (var command in commands)
{
Console.WriteLine("- {0} (LocalizedName:{1}, Classifier:{2})", command.CommandName, command.LocalizedName,
command.Classifier);
}
}
}

void ExecuteCommand()
{
if (processId == null)
{
Console.WriteLine("The process isn't created. Please, create process instance.");
return;
}

WorkflowCommand? command = null;

do
{
GetAvailableCommands();
Console.Write("Enter command:");
var readLine = Console.ReadLine();
if (readLine != null)
{
var commandName = readLine.ToLower().Trim();
if (commandName == string.Empty)
return;

command = WorkflowInit.Runtime.GetAvailableCommands(processId.Value, currentUser)
.FirstOrDefault(c => c.CommandName.Trim().ToLower() == commandName);
}

if (command == null)
Console.WriteLine("The command isn't found.");
} while (command == null);

WorkflowInit.Runtime.ExecuteCommand(command, currentUser, currentUser);
Console.WriteLine("ExecuteCommand - OK.");
}

void GetAvailableState()
{
if (processId == null)
{
Console.WriteLine("The process isn't created. Please, create process instance.");
return;
}

var states = WorkflowInit.Runtime.GetAvailableStateToSet(processId.Value, Thread.CurrentThread.CurrentCulture);

Console.WriteLine("Available state to set:");

var workflowStates = states as WorkflowState[] ?? states.ToArray();

if (!workflowStates.Any())
{
Console.WriteLine("Not found!");
}
else
{
foreach (var state in workflowStates)
{
Console.WriteLine("- {0}", state.Name);
}
}
}

void SetState()
{
if (processId == null)
{
Console.WriteLine("The process isn't created. Please, create process instance.");
return;
}

WorkflowState? state;
do
{
GetAvailableState();
Console.Write("Enter state:");
var readLine = Console.ReadLine();
var stateName = readLine?.ToLower().Trim();
if (string.IsNullOrEmpty(stateName))
return;

state = WorkflowInit.Runtime.GetAvailableStateToSet(processId.Value, Thread.CurrentThread.CurrentCulture)
.FirstOrDefault(c => c.Name.Trim().ToLower() == stateName);

if (state == null)
Console.WriteLine("The state isn't found.");
else
break;
} while (true);

WorkflowInit.Runtime.SetState(new SetStateParams(processId.Value, state.Name)
{
IdentityId = currentUser,
ImpersonatedIdentityId = currentUser
});
Console.WriteLine("SetState - OK.");
}

void DeleteProcess()
{
if (processId == null)
{
Console.WriteLine("The process isn't created. Please, create process instance.");
return;
}

WorkflowInit.Runtime.DeleteInstance(processId.Value);
Console.WriteLine("DeleteProcess - OK.");
processId = null;
}

The Demo

First, execute the Start command.

...
The process is not created.
CreateInstance - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Draft, CurrentActivity: Draft
Current user is undefined.
Enter code of operation:3
Available commands:
- start (LocalizedName:start, Classifier:Direct)
Enter command:start
ExecuteCommand - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1

When you receive the list of available commands, the only command you will see is Deny. For command execution, set the user id. You can do it by using 0. It will set current user operation.

ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1
Current user is undefined.
Enter code of operation:3
Available commands:
- deny (LocalizedName:deny, Classifier:Reverse)
Enter command:
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1
Current user is undefined.
Enter code of operation:0
Enter user id:user1

Now you can get and execute the Approve command. Execute this command and get the list of available commands. Once more, you will only see the Deny command. You need to change user to user2 and execute the Approve command one more time.

ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1
Current user = user1.
...
Enter command:approve
ExecuteCommand - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1
Current user = user1.
Enter code of operation:3
Available commands:
- deny (LocalizedName:deny, Classifier:Reverse)
Enter command:
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1
Current user = user1.
Enter code of operation:0
Enter user id:user2
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage1, CurrentActivity: Stage1
Current user = user2.
Enter code of operation:3
Available commands:
- approve (LocalizedName:approve, Classifier:Direct)
- deny (LocalizedName:deny, Classifier:Reverse)
Enter command:approve
ExecuteCommand - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2

We can see that the process has changed its state to Stage2. It means that the first state was completed. Now you need to execute the Approve command for user3, user4 and user5 to obtain document approval from all the participants.

ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2
Current user = user2.
Enter code of operation:0
Enter user id:user3
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2
Current user = user3.
Enter code of operation:3
...
Enter command:approve
ExecuteCommand - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2
Current user = user3.
Enter code of operation:0
Enter user id:user4
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2
Current user = user4.
...
Enter command:approve
ExecuteCommand - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2
Current user = user4.
Enter code of operation:0
Enter user id:user5
ProcessId = 'b6f1b7d4-...'. CurrentState: Stage2, CurrentActivity: Stage2
Current user = user5.
...
Enter command:approve
ExecuteCommand - OK.
ProcessId = 'b6f1b7d4-...'. CurrentState: Final, CurrentActivity: Final

Conclusion

Connection string

Don't forget to adjust the connection string to the location of your database.