Build a BPM Workflow Engine and Task Portal
Build a state machine workflow engine and React task portal in .NET and SQL Server.
Introduction
30 Second Summary
Think about how companies handle approvals. Someone submits a leave request, it lands in the right manager's queue, and the process advances automatically through defined steps. That's business process management in action.
In this project, you will build a workflow engine and task portal that replicates the core architecture of Genpact's Cora SeQuence, an enterprise iBPMS platform used by large organizations to automate business workflows.
What You'll Build
A mini BPM system with a C#/ASP.NET Core state machine workflow engine backed by SQL Server, and a React portal where users start processes, view assigned tasks, and complete them.
By the end of this project, you'll have:
- A state machine workflow engine in C# that processes workflow definitions and routes tasks to users.
- A SQL Server database storing workflow definitions, running instances, and assigned tasks.
- A React task portal inspired by Cora SeQuence's Flowtime where users start processes, view tasks, and complete them.
- Secret Mission: Add role-based task assignment so managers automatically receive approval tasks while requesters see only their own submissions.
Are there any prerequisites?
You should have a basic understanding of web APIs and databases. Familiarity with JavaScript and React is helpful but not required.
You will also need Visual Studio, the .NET 10 SDK, SQL Server, and Node.js 20 installed on your machine.
Set Up Both Projects
Cora SeQuence's architecture separates its system into independent modules: one for the workflow engine (Background Runtime Service) and one for the user-facing portal (Flowtime). Your project mirrors this with two separate projects: a C# backend that runs the workflow engine and a React frontend that serves as the task portal.
In this step, you'll create both project folders on your Desktop, install the packages the backend needs to communicate with SQL Server, and confirm everything builds cleanly before writing any application code.
In this step, get ready to:
- Create the ASP.NET Core Web API backend project with Entity Framework Core packages.
- Scaffold the React frontend portal with Vite.
- Verify both projects build and run without errors.
Create the ASP.NET Core Web API project
The backend is an ASP.NET Core Web API that uses controllers to handle HTTP requests. Entity Framework Core connects it to SQL Server so the engine can store workflow definitions, instances, and tasks.
- Press the Windows key to open your search bar.
- Type Terminal and press Enter to open Windows Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the backend project and install its database packages by running these commands:
dotnet new webapi --use-controllers -o CoraEngine
cd CoraEngine
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.6
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.6
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 10.0.6
What do these commands do?
- dotnet new webapi --use-controllers -o CoraEngine scaffolds a new ASP.NET Core Web API project with controller-based routing inside a folder called CoraEngine.
- The three dotnet add package commands install Entity Framework Core's SQL Server provider, design-time tools for migrations, and the CLI tooling package. All three are pinned to version 10.0.6 to match your .NET 10 SDK.
- Confirm the packages installed by checking the terminal output. You should see PackageReference for package 'Microsoft.EntityFrameworkCore.SqlServer' ... added (and similar lines for the other two packages).
Seeing a package version error?
If the terminal says the package version was not found, check your internet connection and try the command again. The packages are hosted on NuGet and require network access to download.
If you see a message about .NET SDK not being recognized, confirm you have .NET 10 installed by running dotnet --version. You should see a version starting with 10.0.
Help me troubleshoot my dotnet package install error.
- Now open the project in Visual Studio. Press the Windows key, type Visual Studio, and press Enter to launch it.
- In Visual Studio, click Open a project or solution (or go to File > Open > Folder).
- Navigate to your Desktop, select the CoraEngine folder, and click Select Folder.
Why open the folder, not a file?
Visual Studio loads the entire project structure when you open the folder. This gives you access to the Solution Explorer, IntelliSense, and the integrated terminal for running commands later.
Scaffold the React portal
The frontend is a Vite-powered React app that will serve as the task portal. Vite scaffolds a fast development server so you can see changes instantly as you build the portal's pages.
- Open a second terminal window. Press the Windows key, type Terminal, and press Enter.
- Navigate back to your Desktop by running this command:
cd ~/Desktop
- Scaffold the React project and install its dependencies by running these commands:
npm create vite@latest cora-portal -- --template react
cd cora-portal
npm install
What do these commands do?
- npm create vite@latest cora-portal -- --template react scaffolds a new React project in a folder called cora-portal using Vite's official React template.
- The -- separator passes --template react directly to the Vite scaffold tool (not to npm).
- npm install downloads all the project's dependencies listed in package.json into a local node_modules folder.
- Confirm the install succeeded. You should see a message like added X packages in the terminal output with no errors.
Scaffold command failed?
If you see a permission error, try running the terminal as Administrator. Right-click the Terminal icon and select Run as administrator.
If the command hangs or fails with a network error, check your internet connection. The scaffold downloads the Vite template from the npm registry.
Help me troubleshoot my Vite scaffold error.
Verify both projects build and run
Before writing any application code, confirm that both scaffolded projects compile and serve cleanly. This catches environment issues early.
- Switch to your first terminal (the one inside CoraEngine).
- Build the backend project by running this command:
dotnet build
- You should see Build succeeded with 0 errors in the output.
Build failed?
If you see errors about missing packages, run dotnet restore first, then try dotnet build again. The restore command re-downloads any packages that did not install correctly.
Help me fix my dotnet build errors.
- Switch to your second terminal (the one inside cora-portal).
- Start the Vite development server by running:
npm run dev
- The terminal displays a local URL. Open http://localhost:5173 in your browser.
- You should see the default Vite + React welcome page with the Vite and React logos and a counter button.
Port 5173 not working?
If the terminal shows a different port (like 5174), another process is using 5173. Use whatever port the terminal displays instead.
If the command fails entirely, confirm you are inside the cora-portal folder by checking that package.json exists there. Run ls (or dir on Windows) to check.
Help me get the Vite dev server running.
Both projects are scaffolded and running. Next up, you'll define the database schema that stores workflow definitions, running instances, and tasks.
Build the Database Schema
Your projects are scaffolded and ready. Now you need a place to store workflow data.
Every BPM system separates the blueprint (what a workflow looks like) from the instance (a running process following that blueprint). In this step, you will define that data model in C# and let Entity Framework Core generate the actual SQL Server tables for you.
In this step, get ready to:
- Create EF Core model classes for workflow definitions, instances, and tasks.
- Configure the database context and connection string.
- Run migrations to create the database tables in SQL Server.
Create the EF Core models
Your workflow engine needs three database entities: one for the workflow blueprint, one for a running process, and one for an individual task assigned to a user. You will also create a helper class that describes the shape of the workflow JSON.
- In Visual Studio's Solution Explorer, right-click the CoraEngine project.
- Select Add, then New Folder.
- Name the folder Models.
- Right-click the Models folder, select Add then Class, and name it WorkflowDefinition.cs.
- Replace the entire file contents with this code:
namespace CoraEngine.Models;
public class WorkflowDefinition
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
// Stores the workflow states and transitions as a JSON string
public string DefinitionJson { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
What does this model represent?
A WorkflowDefinition is the blueprint for a process. It stores the workflow's name, description, and the full state machine structure as a JSON string in DefinitionJson. Think of it like a recipe that can be followed many times.
- Add another class to the Models folder named WorkflowInstance.cs.
- Replace the file contents with:
namespace CoraEngine.Models;
public class WorkflowInstance
{
public int Id { get; set; }
// Links back to the definition this instance follows
public int WorkflowDefinitionId { get; set; }
public WorkflowDefinition WorkflowDefinition { get; set; } = null!;
// Tracks which state the process is currently in
public string CurrentState { get; set; } = string.Empty;
public string StartedBy { get; set; } = string.Empty;
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public bool IsCompleted { get; set; } = false;
}
What does this model represent?
A WorkflowInstance is a single running process. When someone starts a Leave Request, that creates one instance pinned to the Leave Request definition. The CurrentState field tracks where in the workflow this process currently sits.
- Add another class named WorkflowTask.cs to the Models folder.
- Replace the file contents with:
namespace CoraEngine.Models;
public class WorkflowTask
{
public int Id { get; set; }
// Links to the instance this task belongs to
public int WorkflowInstanceId { get; set; }
public WorkflowInstance WorkflowInstance { get; set; } = null!;
public string Name { get; set; } = string.Empty;
// Which user or role this task is assigned to
public string AssignedTo { get; set; } = string.Empty;
// The workflow state when this task was created
public string State { get; set; } = string.Empty;
public bool IsCompleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
What does this model represent?
A WorkflowTask is an actionable item assigned to a specific user at a specific point in the workflow. When the engine needs someone to approve a request, it creates a task and assigns it to them.
- Add one more class named WorkflowSchema.cs to the Models folder.
- Replace the file contents with:
namespace CoraEngine.Models;
public class WorkflowSchema
{
// The state every new instance starts in
public string InitialState { get; set; } = string.Empty;
// States where the workflow is considered finished
public List<string> TerminalStates { get; set; } = new();
// All possible transitions between states
public List<WorkflowTransition> Transitions { get; set; } = new();
}
public class WorkflowTransition
{
public string FromState { get; set; } = string.Empty;
public string ToState { get; set; } = string.Empty;
// The action name that triggers this transition (e.g., "Approve")
public string Action { get; set; } = string.Empty;
// The role responsible for the task at this step
public string AssignedRole { get; set; } = string.Empty;
public string TaskName { get; set; } = string.Empty;
}
Why is WorkflowSchema not a database table?
This class is a helper for deserializing the JSON stored inside WorkflowDefinition.DefinitionJson. It describes the structure of the state machine (initial state, terminal states, and transitions) but does not map to its own database table.
- Save all four files in the Models folder.
- Confirm the project still builds by running this in the terminal from the CoraEngine folder:
dotnet build
You should see a "Build succeeded" message with no errors.
Seeing build errors?
- Check that each file starts with namespace CoraEngine.Models; and matches the project namespace exactly.
- Make sure WorkflowInstance.cs references WorkflowDefinition (not WorkflowDefinitions plural).
- Verify the files are saved inside the Models folder, not in the project root.
Still stuck? Help me fix a build error in my CoraEngine Models folder.
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved before continuing.
ⓧ I'd like to double check the full code
namespace CoraEngine.Models;
public class WorkflowDefinition
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string DefinitionJson { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
namespace CoraEngine.Models;
public class WorkflowInstance
{
public int Id { get; set; }
public int WorkflowDefinitionId { get; set; }
public WorkflowDefinition WorkflowDefinition { get; set; } = null!;
public string CurrentState { get; set; } = string.Empty;
public string StartedBy { get; set; } = string.Empty;
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public bool IsCompleted { get; set; } = false;
}
namespace CoraEngine.Models;
public class WorkflowTask
{
public int Id { get; set; }
public int WorkflowInstanceId { get; set; }
public WorkflowInstance WorkflowInstance { get; set; } = null!;
public string Name { get; set; } = string.Empty;
public string AssignedTo { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public bool IsCompleted { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
namespace CoraEngine.Models;
public class WorkflowSchema
{
public string InitialState { get; set; } = string.Empty;
public List<string> TerminalStates { get; set; } = new();
public List<WorkflowTransition> Transitions { get; set; } = new();
}
public class WorkflowTransition
{
public string FromState { get; set; } = string.Empty;
public string ToState { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public string AssignedRole { get; set; } = string.Empty;
public string TaskName { get; set; } = string.Empty;
}
Create the DbContext and configure the connection
Entity Framework Core needs a DbContext class to know which models map to database tables. You also need a connection string so EF Core knows where your SQL Server database lives.
- Right-click the CoraEngine project in Solution Explorer, select Add then New Folder, and name it Data.
- Add a new class inside Data named BpmDbContext.cs.
- Replace the file contents with:
using Microsoft.EntityFrameworkCore;
using CoraEngine.Models;
namespace CoraEngine.Data;
public class BpmDbContext : DbContext
{
public BpmDbContext(DbContextOptions<BpmDbContext> options) : base(options) { }
// Each DbSet maps a model to a database table
public DbSet<WorkflowDefinition> WorkflowDefinitions { get; set; }
public DbSet<WorkflowInstance> WorkflowInstances { get; set; }
public DbSet<WorkflowTask> WorkflowTasks { get; set; }
}
What does a DbContext do?
The BpmDbContext acts as a bridge between your C# models and the SQL Server database. Each DbSet property tells EF Core to create and manage a corresponding table.
The constructor accepts DbContextOptions so the framework can inject the connection string and provider configuration at runtime.
- Open appsettings.json in the CoraEngine project root.
- Add a ConnectionStrings section so the full file looks like this:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=CoraEngine;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
What does this connection string do?
- Server=localhost points to your local SQL Server instance.
- Database=CoraEngine names the database EF Core will create.
- Trusted_Connection=True uses Windows Authentication (no username/password needed).
- TrustServerCertificate=True skips certificate validation for local development.
- Open Program.cs in the project root.
- Replace its entire contents with the following updated code:
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// Register the database context with the SQL Server provider
builder.Services.AddDbContext<BpmDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
What changed in Program.cs?
- using Microsoft.EntityFrameworkCore; brings in the UseSqlServer extension method.
- using CoraEngine.Data; makes BpmDbContext visible.
- AddDbContext<BpmDbContext>(...) registers the context in dependency injection, reading the connection string from appsettings.json.
- Save BpmDbContext.cs, appsettings.json, and Program.cs.
- Run dotnet build in the terminal to confirm everything compiles cleanly.
You should see "Build succeeded" again.
Build failed after adding the DbContext?
- Confirm BpmDbContext.cs uses namespace CoraEngine.Data; (not CoraEngine.Models).
- Make sure Program.cs has using CoraEngine.Data; at the top.
- Check that the ConnectionStrings key in appsettings.json is valid JSON (no trailing commas, proper quotes).
Still stuck? Help me fix a build error after adding BpmDbContext to my CoraEngine project.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before running migrations.
ⓧ I'd like to double check the full code
using Microsoft.EntityFrameworkCore;
using CoraEngine.Models;
namespace CoraEngine.Data;
public class BpmDbContext : DbContext
{
public BpmDbContext(DbContextOptions<BpmDbContext> options) : base(options) { }
public DbSet<WorkflowDefinition> WorkflowDefinitions { get; set; }
public DbSet<WorkflowInstance> WorkflowInstances { get; set; }
public DbSet<WorkflowTask> WorkflowTasks { get; set; }
}
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=CoraEngine;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<BpmDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
Run EF Core migrations to create the database
EF Core migrations translate your model classes into SQL commands that create the actual database tables. You run two commands: one to generate the migration file, and one to apply it to SQL Server.
- In your terminal, make sure you are inside the CoraEngine folder.
- Generate the initial migration and apply it to the database by running these commands:
dotnet ef migrations add InitialCreate
dotnet ef database update
What do these commands do?
- dotnet ef migrations add InitialCreate scans your DbContext and models, then generates a Migrations folder with C# code describing the schema changes.
- dotnet ef database update connects to SQL Server, creates the CoraEngine database if it does not exist, and executes the migration to create the tables.
- Open SQL Server Management Studio (or your preferred database tool).
- Connect to localhost.
- Expand the CoraEngine database, then expand Tables.
- Confirm you see three tables: WorkflowDefinitions, WorkflowInstances, and WorkflowTasks.
Migration command not found?
- If you see a "No executable found" error, install the EF Core CLI tool globally by running:
- dotnet tool install --global dotnet-ef then retry the migration commands.
- Make sure the Microsoft.EntityFrameworkCore.Design package is installed in your project (it was added in Step 1).
- Confirm you are running the commands from inside the CoraEngine folder (where the .csproj file lives).
Still stuck? Help me troubleshoot EF Core migrations not running in my CoraEngine project.
Your database is live with tables ready to store workflow data. Next up, you will build the workflow engine service that brings these tables to life by starting processes and routing tasks.
Build the Workflow Engine
Your database is ready with tables for definitions, instances, and tasks. Now you need the brain that ties them together.
In this step, you will build the workflow engine service. This is the core component that reads a workflow definition, creates running instances, and advances tasks through states based on user actions.
In this step, get ready to:
- Seed a Leave Request workflow definition into the database.
- Build the WorkflowEngineService with methods to start processes, complete tasks, and get available actions.
- Register the engine service in dependency injection.
Seed the Leave Request workflow definition
Before the engine can process anything, it needs a workflow definition to read from. A workflow definition is a JSON blueprint that describes the allowed states and the transitions that move a process between them.
What is a state machine?
A state machine is a pattern where a process can only be in one state at a time. Specific actions move it to another state. For example, a Leave Request starts in "Submitted", moves to "PendingApproval", and ends in "Approved" or "Rejected".
Enterprise BPM systems like Cora SeQuence use this pattern to enforce that processes follow a defined path. No one can skip steps or jump to an invalid state.
- Open SQL Server Management Studio (or your preferred SQL tool) and connect to your local SQL Server instance.
- Select the CoraEngine database.
- Seed the Leave Request workflow definition by running this SQL query:
INSERT INTO WorkflowDefinitions (Name, Description, DefinitionJson, CreatedAt)
VALUES (
'Leave Request',
'Standard leave request approval workflow',
'{"InitialState":"Submitted","TerminalStates":["Approved","Rejected"],"Transitions":[{"FromState":"Submitted","ToState":"PendingApproval","Action":"Submit","AssignedRole":"requester","TaskName":"Submit Leave Request"},{"FromState":"PendingApproval","ToState":"Approved","Action":"Approve","AssignedRole":"manager","TaskName":"Review Leave Request"},{"FromState":"PendingApproval","ToState":"Rejected","Action":"Reject","AssignedRole":"manager","TaskName":"Review Leave Request"}]}',
GETUTCDATE()
);
What does this JSON define?
- InitialState is "Submitted". Every new Leave Request starts here.
- TerminalStates are "Approved" and "Rejected". Once a process reaches either state, it is complete.
- Each transition defines a FromState, a ToState, the Action that triggers it, and the AssignedRole responsible for performing it.
- Verify the seed worked by running this query:
SELECT Id, Name, Description FROM WorkflowDefinitions;
You should see one row with the name "Leave Request" in the results.
No rows returned?
- Make sure you selected the CoraEngine database before running the INSERT. If you ran it against master, the table does not exist there.
- Check that the INSERT completed without errors. A mismatched quote in the JSON string can cause a silent failure.
- help me troubleshoot my seed query
Build the WorkflowEngineService
The engine service is the core of your BPM system. It reads workflow definitions from the database, creates running instances, and advances them through states as users complete tasks.
- In the CoraEngine project in Visual Studio, right-click the project and select Add then New Folder.
- Name the folder Services.
- Right-click the Services folder and select Add then Class.
- Name the file WorkflowEngineService.cs.
- Replace the file contents with the following code to set up the class and the StartProcess method:
using System.Text.Json;
using CoraEngine.Data;
using CoraEngine.Models;
using Microsoft.EntityFrameworkCore;
namespace CoraEngine.Services;
public class WorkflowEngineService
{
private readonly BpmDbContext _db;
public WorkflowEngineService(BpmDbContext db)
{
_db = db;
}
// Starts a new workflow process from a definition
public async Task<WorkflowInstance> StartProcess(int definitionId, string startedBy)
{
// Load the workflow definition from the database
var definition = await _db.WorkflowDefinitions.FindAsync(definitionId);
if (definition == null) throw new Exception("Workflow definition not found");
// Parse the JSON blueprint into a WorkflowSchema object
var schema = JsonSerializer.Deserialize<WorkflowSchema>(definition.DefinitionJson)!;
// Create a new instance pinned to the initial state
var instance = new WorkflowInstance
{
WorkflowDefinitionId = definitionId,
CurrentState = schema.InitialState,
StartedBy = startedBy,
StartedAt = DateTime.UtcNow
};
_db.WorkflowInstances.Add(instance);
await _db.SaveChangesAsync();
// Find transitions from the initial state and create the first task
var firstTransitions = schema.Transitions
.Where(t => t.FromState == schema.InitialState)
.ToList();
if (firstTransitions.Any())
{
var transition = firstTransitions.First();
var task = new WorkflowTask
{
WorkflowInstanceId = instance.Id,
Name = transition.TaskName,
AssignedTo = transition.AssignedRole,
State = schema.InitialState,
CreatedAt = DateTime.UtcNow
};
_db.WorkflowTasks.Add(task);
await _db.SaveChangesAsync();
}
return instance;
}
}
What does StartProcess do?
- It loads the workflow definition from the database and deserializes its JSON into a WorkflowSchema object.
- It creates a WorkflowInstance pinned to the definition's initial state ("Submitted" for Leave Request).
- It looks at the transitions leaving the initial state and creates the first WorkflowTask so someone can act on it.
- Save WorkflowEngineService.cs.
You will not see a visible change yet. The next method makes the engine functional end-to-end.
- Add the CompleteTask method below the closing brace of StartProcess, but still inside the class:
// Completes a task and advances the workflow to the next state
public async Task<WorkflowInstance> CompleteTask(int taskId, string action)
{
// Load the task with its parent instance and workflow definition
var task = await _db.WorkflowTasks
.Include(t => t.WorkflowInstance)
.ThenInclude(i => i.WorkflowDefinition)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (task == null) throw new Exception("Task not found");
// Deserialize the workflow schema to find valid transitions
var schema = JsonSerializer.Deserialize<WorkflowSchema>(
task.WorkflowInstance.WorkflowDefinition.DefinitionJson)!;
// Find the transition matching the current state and chosen action
var transition = schema.Transitions
.FirstOrDefault(t => t.FromState == task.WorkflowInstance.CurrentState && t.Action == action);
if (transition == null) throw new Exception("Invalid action for current state");
// Mark the current task as completed
task.IsCompleted = true;
task.CompletedAt = DateTime.UtcNow;
// Move the instance to the new state
task.WorkflowInstance.CurrentState = transition.ToState;
// Check if we reached a terminal state
if (schema.TerminalStates.Contains(transition.ToState))
{
task.WorkflowInstance.IsCompleted = true;
}
else
{
// Not terminal, so create the next task
var nextTransitions = schema.Transitions
.Where(t => t.FromState == transition.ToState)
.ToList();
if (nextTransitions.Any())
{
var nextTransition = nextTransitions.First();
var nextTask = new WorkflowTask
{
WorkflowInstanceId = task.WorkflowInstanceId,
Name = nextTransition.TaskName,
AssignedTo = nextTransition.AssignedRole,
State = transition.ToState,
CreatedAt = DateTime.UtcNow
};
_db.WorkflowTasks.Add(nextTask);
}
}
await _db.SaveChangesAsync();
return task.WorkflowInstance;
}
What does CompleteTask do?
- It loads the task along with its parent instance and the original workflow definition using Entity Framework Core's Include and ThenInclude for eager loading.
- It validates that the chosen action is a legal transition from the current state.
- It marks the task done, advances the instance to the next state, and creates a new task if the workflow has not reached a terminal state.
- Save WorkflowEngineService.cs.
You will not see a visible change yet. The final method completes the service.
- Add the GetAvailableActions method below the closing brace of CompleteTask, still inside the class:
// Returns the valid actions a user can take on a given task
public async Task<List<string>> GetAvailableActions(int taskId)
{
// Load the task with its workflow context
var task = await _db.WorkflowTasks
.Include(t => t.WorkflowInstance)
.ThenInclude(i => i.WorkflowDefinition)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (task == null) return new List<string>();
var schema = JsonSerializer.Deserialize<WorkflowSchema>(
task.WorkflowInstance.WorkflowDefinition.DefinitionJson)!;
// Return all action names for transitions leaving the current state
return schema.Transitions
.Where(t => t.FromState == task.WorkflowInstance.CurrentState)
.Select(t => t.Action)
.ToList();
}
What does GetAvailableActions do?
It reads the workflow schema and returns the list of action names (like "Approve" or "Reject") that are valid from the current state. The React portal will use this later to render action buttons on each task.
- Save WorkflowEngineService.cs.
- Build the project by pressing Ctrl+Shift+B in Visual Studio.
The build should succeed with no errors. If it does, your engine service compiles correctly against the models and DbContext from the previous step.
Build errors in WorkflowEngineService?
- Check that all four using statements are at the top of the file. Missing using Microsoft.EntityFrameworkCore; causes Include and ThenInclude to show as unrecognized.
- Make sure GetAvailableActions and CompleteTask are inside the WorkflowEngineService class braces. A misplaced closing brace can push methods outside the class.
- help me fix build errors in my WorkflowEngineService
✔️ Awesome, I've got everything!
Great. Double check you have saved WorkflowEngineService.cs and the build succeeds.
ⓧ I'd like to double check the full code
using System.Text.Json;
using CoraEngine.Data;
using CoraEngine.Models;
using Microsoft.EntityFrameworkCore;
namespace CoraEngine.Services;
public class WorkflowEngineService
{
private readonly BpmDbContext _db;
public WorkflowEngineService(BpmDbContext db)
{
_db = db;
}
public async Task<WorkflowInstance> StartProcess(int definitionId, string startedBy)
{
var definition = await _db.WorkflowDefinitions.FindAsync(definitionId);
if (definition == null) throw new Exception("Workflow definition not found");
var schema = JsonSerializer.Deserialize<WorkflowSchema>(definition.DefinitionJson)!;
var instance = new WorkflowInstance
{
WorkflowDefinitionId = definitionId,
CurrentState = schema.InitialState,
StartedBy = startedBy,
StartedAt = DateTime.UtcNow
};
_db.WorkflowInstances.Add(instance);
await _db.SaveChangesAsync();
var firstTransitions = schema.Transitions
.Where(t => t.FromState == schema.InitialState)
.ToList();
if (firstTransitions.Any())
{
var transition = firstTransitions.First();
var task = new WorkflowTask
{
WorkflowInstanceId = instance.Id,
Name = transition.TaskName,
AssignedTo = transition.AssignedRole,
State = schema.InitialState,
CreatedAt = DateTime.UtcNow
};
_db.WorkflowTasks.Add(task);
await _db.SaveChangesAsync();
}
return instance;
}
public async Task<WorkflowInstance> CompleteTask(int taskId, string action)
{
var task = await _db.WorkflowTasks
.Include(t => t.WorkflowInstance)
.ThenInclude(i => i.WorkflowDefinition)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (task == null) throw new Exception("Task not found");
var schema = JsonSerializer.Deserialize<WorkflowSchema>(
task.WorkflowInstance.WorkflowDefinition.DefinitionJson)!;
var transition = schema.Transitions
.FirstOrDefault(t => t.FromState == task.WorkflowInstance.CurrentState && t.Action == action);
if (transition == null) throw new Exception("Invalid action for current state");
task.IsCompleted = true;
task.CompletedAt = DateTime.UtcNow;
task.WorkflowInstance.CurrentState = transition.ToState;
if (schema.TerminalStates.Contains(transition.ToState))
{
task.WorkflowInstance.IsCompleted = true;
}
else
{
var nextTransitions = schema.Transitions
.Where(t => t.FromState == transition.ToState)
.ToList();
if (nextTransitions.Any())
{
var nextTransition = nextTransitions.First();
var nextTask = new WorkflowTask
{
WorkflowInstanceId = task.WorkflowInstanceId,
Name = nextTransition.TaskName,
AssignedTo = nextTransition.AssignedRole,
State = transition.ToState,
CreatedAt = DateTime.UtcNow
};
_db.WorkflowTasks.Add(nextTask);
}
}
await _db.SaveChangesAsync();
return task.WorkflowInstance;
}
public async Task<List<string>> GetAvailableActions(int taskId)
{
var task = await _db.WorkflowTasks
.Include(t => t.WorkflowInstance)
.ThenInclude(i => i.WorkflowDefinition)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (task == null) return new List<string>();
var schema = JsonSerializer.Deserialize<WorkflowSchema>(
task.WorkflowInstance.WorkflowDefinition.DefinitionJson)!;
return schema.Transitions
.Where(t => t.FromState == task.WorkflowInstance.CurrentState)
.Select(t => t.Action)
.ToList();
}
}
Register the service in dependency injection
ASP.NET Core uses dependency injection to provide services to controllers. You need to register your engine service so controllers can request it in the next step.
- In Program.cs, find this line:
builder.Services.AddDbContext<BpmDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
- Add the following line directly below it:
builder.Services.AddScoped<WorkflowEngineService>();
Why AddScoped?
AddScoped creates one instance of the service per HTTP request. This matches the lifetime of BpmDbContext which is also scoped. Each request gets its own engine with its own database context, keeping requests isolated from each other.
- Save Program.cs.
- Build the project again with Ctrl+Shift+B. It should still build successfully.
Build error on the AddScoped line?
- Confirm that using CoraEngine.Services; is already at the top of Program.cs. This was added when the project was scaffolded.
- Make sure the line references WorkflowEngineService exactly. A typo in the class name causes a compilation error.
- help me fix the AddScoped registration error
Your workflow engine is complete. Next up, you will build the REST API controllers that expose this engine to the outside world.
Build the REST API
Your workflow engine can start processes, complete tasks, and advance state. But right now, the only way to use it is from inside the C# code itself.
In this step, you will expose the engine through a REST API so that any client (a browser, a React app, or a test tool) can trigger workflow actions over HTTP.
In this step, get ready to:
- Create a WorkflowsController for listing definitions and starting processes.
- Create a TasksController for querying and completing tasks.
- Enable CORS and test the full API from your browser.
Create the WorkflowsController
Controllers are the HTTP entry points into your API. Each controller groups related endpoints under a shared route prefix.
- In Visual Studio, right-click the Controllers folder inside your CoraEngine project.
- If the Controllers folder does not exist yet, create it at the root of the CoraEngine project.
- Add a new file called WorkflowsController.cs and paste the following code:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
using CoraEngine.Services;
namespace CoraEngine.Controllers;
[ApiController]
[Route("api/[controller]")]
public class WorkflowsController : ControllerBase
{
private readonly BpmDbContext _db;
private readonly WorkflowEngineService _engine;
public WorkflowsController(BpmDbContext db, WorkflowEngineService engine)
{
_db = db;
_engine = engine;
}
// GET /api/workflows - returns all workflow definitions
[HttpGet]
public async Task<IActionResult> GetAll()
{
var definitions = await _db.WorkflowDefinitions.ToListAsync();
return Ok(definitions);
}
// POST /api/workflows/{id}/start - starts a new process instance
[HttpPost("{id}/start")]
public async Task<IActionResult> StartProcess(int id, [FromBody] StartProcessRequest request)
{
var instance = await _engine.StartProcess(id, request.StartedBy);
return Ok(instance);
}
}
// Request body shape for starting a process
public class StartProcessRequest
{
public string StartedBy { get; set; } = string.Empty;
}
What does this code do?
The [ApiController] attribute enables automatic model validation and route inference. The [Route("api/[controller]")] attribute sets the base URL to /api/workflows (derived from the class name minus "Controller").
The GetAll method returns every workflow definition in your database. The StartProcess method accepts a JSON body with a StartedBy field and delegates to WorkflowEngineService to create the instance and first task.
- Save WorkflowsController.cs.
You will not see a visible change yet because you still need the tasks controller and CORS before testing.
Create the TasksController
The tasks controller handles the other side of the BPM workflow. It lets clients query assigned tasks, view task details with available actions, and mark tasks as complete.
- In the same Controllers folder, add a new file called TasksController.cs and paste this code:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
using CoraEngine.Services;
namespace CoraEngine.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
private readonly BpmDbContext _db;
private readonly WorkflowEngineService _engine;
public TasksController(BpmDbContext db, WorkflowEngineService engine)
{
_db = db;
_engine = engine;
}
// GET /api/tasks?assignedTo={user} - returns open tasks, optionally filtered
[HttpGet]
public async Task<IActionResult> GetTasks([FromQuery] string? assignedTo)
{
var query = _db.WorkflowTasks.Where(t => !t.IsCompleted);
if (!string.IsNullOrEmpty(assignedTo))
{
query = query.Where(t => t.AssignedTo == assignedTo);
}
var tasks = await query.OrderByDescending(t => t.CreatedAt).ToListAsync();
return Ok(tasks);
}
// GET /api/tasks/{id} - returns task detail plus available actions
[HttpGet("{id}")]
public async Task<IActionResult> GetTask(int id)
{
var task = await _db.WorkflowTasks.FindAsync(id);
if (task == null) return NotFound();
var actions = await _engine.GetAvailableActions(id);
return Ok(new { task, availableActions = actions });
}
// POST /api/tasks/{id}/complete - completes a task with the chosen action
[HttpPost("{id}/complete")]
public async Task<IActionResult> CompleteTask(int id, [FromBody] CompleteTaskRequest request)
{
var instance = await _engine.CompleteTask(id, request.Action);
return Ok(instance);
}
}
// Request body shape for completing a task
public class CompleteTaskRequest
{
public string Action { get; set; } = string.Empty;
}
What does this code do?
The GetTasks endpoint filters out completed tasks and optionally narrows results to a specific user via the assignedTo query parameter. This is how the React portal will fetch tasks for a particular user.
The GetTask endpoint returns a single task alongside its available actions (like "Approve" or "Reject"). The CompleteTask endpoint accepts an action string and delegates to the engine to advance the workflow.
- Save TasksController.cs.
Seeing a red squiggly under a type name?
Make sure the using statements at the top match exactly. Microsoft.EntityFrameworkCore is needed for ToListAsync and FindAsync.
help me fix unresolved type errors in my ASP.NET Core controller
Enable CORS and test with the browser
Your React portal will run at http://localhost:5173 and needs to call this API. Browsers block cross-origin requests by default, so you need a CORS policy to allow it.
- Open Program.cs in the CoraEngine project.
- Find this line:
builder.Services.AddScoped<WorkflowEngineService>();
- Directly below it, add the CORS configuration:
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowReactDev", policy =>
{
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
- Now find this line further down in the same file:
app.UseHttpsRedirection();
- Directly below it, add:
app.UseCors("AllowReactDev");
What is CORS?
Cross-Origin Resource Sharing (CORS) is a browser security mechanism. When your React app at http://localhost:5173 tries to call an API at a different origin, the browser blocks it unless the API explicitly allows that origin.
The AllowReactDev policy tells ASP.NET Core to include the correct response headers so the browser permits the request.
- Save Program.cs.
- Start the API by running this command in the terminal inside the CoraEngine folder:
dotnet run
The terminal will display the URL the API is listening on. Look for a line like Now listening on: http://localhost:XXXX. Note this port number.
Check your port number
ASP.NET Core assigns a random HTTP port between 5000 and 5300 when you create the project. Your port is stored in Properties/launchSettings.json. You will need this port when connecting the React portal in the next step.
- Test the workflows endpoint by opening your browser and navigating to http://localhost:YOUR_PORT/api/workflows (replacing YOUR_PORT with the port from the terminal output).
You should see a JSON array containing the seeded Leave Request workflow definition.
- Now test starting a process. Open a new terminal in the CoraEngine folder and run this command (replace YOUR_PORT with your actual port):
curl -X POST http://localhost:[[API_PORT="your API port from the terminal output"]]/api/workflows/1/start -H "Content-Type: application/json" -d "{\"startedBy\":\"requester\"}"
You should see a JSON response containing the new workflow instance with currentState set to the initial state.
- Verify a task was created by navigating to http://localhost:YOUR_PORT/api/tasks in your browser.
You should see a JSON array with one task. The task has a name, an assignedTo value, and isCompleted set to false.
Getting a 404 or connection refused?
Make sure dotnet run is still active in your terminal. The API only responds while the process is running.
Double-check that the port number in your browser URL matches the port shown in the terminal output. A mismatch is the most common cause of connection failures.
help me debug why my ASP.NET Core API endpoints are returning 404
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: WorkflowsController.cs, TasksController.cs, and Program.cs.
ⓧ I'd like to double check the full code
Here is the complete code for all three files updated in this step.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
using CoraEngine.Services;
namespace CoraEngine.Controllers;
[ApiController]
[Route("api/[controller]")]
public class WorkflowsController : ControllerBase
{
private readonly BpmDbContext _db;
private readonly WorkflowEngineService _engine;
public WorkflowsController(BpmDbContext db, WorkflowEngineService engine)
{
_db = db;
_engine = engine;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var definitions = await _db.WorkflowDefinitions.ToListAsync();
return Ok(definitions);
}
[HttpPost("{id}/start")]
public async Task<IActionResult> StartProcess(int id, [FromBody] StartProcessRequest request)
{
var instance = await _engine.StartProcess(id, request.StartedBy);
return Ok(instance);
}
}
public class StartProcessRequest
{
public string StartedBy { get; set; } = string.Empty;
}
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
using CoraEngine.Services;
namespace CoraEngine.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
private readonly BpmDbContext _db;
private readonly WorkflowEngineService _engine;
public TasksController(BpmDbContext db, WorkflowEngineService engine)
{
_db = db;
_engine = engine;
}
[HttpGet]
public async Task<IActionResult> GetTasks([FromQuery] string? assignedTo)
{
var query = _db.WorkflowTasks.Where(t => !t.IsCompleted);
if (!string.IsNullOrEmpty(assignedTo))
{
query = query.Where(t => t.AssignedTo == assignedTo);
}
var tasks = await query.OrderByDescending(t => t.CreatedAt).ToListAsync();
return Ok(tasks);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetTask(int id)
{
var task = await _db.WorkflowTasks.FindAsync(id);
if (task == null) return NotFound();
var actions = await _engine.GetAvailableActions(id);
return Ok(new { task, availableActions = actions });
}
[HttpPost("{id}/complete")]
public async Task<IActionResult> CompleteTask(int id, [FromBody] CompleteTaskRequest request)
{
var instance = await _engine.CompleteTask(id, request.Action);
return Ok(instance);
}
}
public class CompleteTaskRequest
{
public string Action { get; set; } = string.Empty;
}
using Microsoft.EntityFrameworkCore;
using CoraEngine.Data;
using CoraEngine.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<BpmDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<WorkflowEngineService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowReactDev", policy =>
{
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseCors("AllowReactDev");
app.MapControllers();
app.Run();
Your API is live and responding to requests. Next up, you will build the React task portal that consumes these endpoints and gives users a visual interface to start processes and complete tasks.
Build the React Task Portal
Your API is live and tested. You have endpoints to start processes, list tasks, and complete them. Now you need a user interface where people actually interact with these workflows.
In this step you will build a React portal inspired by Cora SeQuence's Flowtime. The portal gives users a task list, a way to start new processes, and action buttons to advance workflows through their states.
In this step, get ready to:
- Build the app layout with a header, sidebar navigation, and content area.
- Create the My Tasks and Start Process pages that talk to your API.
- Create the Task Detail view with action buttons that advance workflow state.
Build the layout and navigation
Enterprise portals like Flowtime use a consistent shell: a branded header, a sidebar for navigation, and a content area that swaps based on the current page. You will replicate this pattern in App.jsx.
Why a single-page app with state instead of a router?
For a small portal with three views, managing navigation with React state is simpler than adding a routing library. The currentView state variable acts as a lightweight page selector.
- In your code editor, open src/App.jsx inside the cora-portal folder.
- Select all the existing content and replace it with the following code:
import { useState } from 'react';
import MyTasks from './components/MyTasks';
import StartProcess from './components/StartProcess';
import TaskDetail from './components/TaskDetail';
import './App.css';
// Point to the .NET API backend
const API_BASE = 'http://localhost:5073';
function App() {
// Track which page is visible
const [currentView, setCurrentView] = useState('tasks');
// Track which task the user clicked into
const [selectedTaskId, setSelectedTaskId] = useState(null);
// Hardcoded user for now
const [currentUser, setCurrentUser] = useState('requester');
const handleViewTask = (taskId) => {
setSelectedTaskId(taskId);
setCurrentView('detail');
};
const handleBack = () => {
setSelectedTaskId(null);
setCurrentView('tasks');
};
return (
<div className="app">
<header className="header">
<h1>Cora Portal</h1>
<div className="user-info">
User: <strong>{currentUser}</strong>
</div>
</header>
<div className="layout">
<nav className="sidebar">
<button
className={currentView === 'tasks' ? 'active' : ''}
onClick={() => setCurrentView('tasks')}
>
My Tasks
</button>
<button
className={currentView === 'start' ? 'active' : ''}
onClick={() => setCurrentView('start')}
>
Start Process
</button>
</nav>
<main className="content">
{currentView === 'tasks' && (
<MyTasks
apiBase={API_BASE}
currentUser={currentUser}
onViewTask={handleViewTask}
/>
)}
{currentView === 'start' && (
<StartProcess apiBase={API_BASE} currentUser={currentUser} />
)}
{currentView === 'detail' && selectedTaskId && (
<TaskDetail
apiBase={API_BASE}
taskId={selectedTaskId}
onBack={handleBack}
/>
)}
</main>
</div>
</div>
);
}
export default App;
What does this code do?
- API_BASE points all fetch calls to your .NET backend.
- currentView controls which component renders in the main area.
- The sidebar buttons switch views. The header shows which user is logged in.
- handleViewTask and handleBack handle navigation into and out of the task detail view.
Check your API port
The code uses port 5073. Your .NET project may have been assigned a different port. Open CoraEngine/Properties/launchSettings.json and look for the applicationUrl under the http profile. Update API_BASE if it differs.
Seeing errors in the browser?
Your app imports three component files that do not exist yet. Vite will show an error overlay until you create them. This is expected. Keep going.
You will not see a working page yet. The next chunks create the missing components.
✔️ Awesome, I've got everything!
Save App.jsx and move on to the stylesheet.
ⓧ I'd like to double check the full code
import { useState } from 'react';
import MyTasks from './components/MyTasks';
import StartProcess from './components/StartProcess';
import TaskDetail from './components/TaskDetail';
import './App.css';
const API_BASE = 'http://localhost:5073';
function App() {
const [currentView, setCurrentView] = useState('tasks');
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [currentUser, setCurrentUser] = useState('requester');
const handleViewTask = (taskId) => {
setSelectedTaskId(taskId);
setCurrentView('detail');
};
const handleBack = () => {
setSelectedTaskId(null);
setCurrentView('tasks');
};
return (
<div className="app">
<header className="header">
<h1>Cora Portal</h1>
<div className="user-info">
User: <strong>{currentUser}</strong>
</div>
</header>
<div className="layout">
<nav className="sidebar">
<button
className={currentView === 'tasks' ? 'active' : ''}
onClick={() => setCurrentView('tasks')}
>
My Tasks
</button>
<button
className={currentView === 'start' ? 'active' : ''}
onClick={() => setCurrentView('start')}
>
Start Process
</button>
</nav>
<main className="content">
{currentView === 'tasks' && (
<MyTasks
apiBase={API_BASE}
currentUser={currentUser}
onViewTask={handleViewTask}
/>
)}
{currentView === 'start' && (
<StartProcess apiBase={API_BASE} currentUser={currentUser} />
)}
{currentView === 'detail' && selectedTaskId && (
<TaskDetail
apiBase={API_BASE}
taskId={selectedTaskId}
onBack={handleBack}
/>
)}
</main>
</div>
</div>
);
}
export default App;
- Now open src/App.css in the same cora-portal folder.
- Replace all existing content with the following stylesheet:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
}
.app {
min-height: 100vh;
}
.header {
background: #1a237e;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.5rem;
}
.user-info {
font-size: 0.9rem;
}
.layout {
display: flex;
min-height: calc(100vh - 60px);
}
.sidebar {
width: 200px;
background: white;
border-right: 1px solid #e0e0e0;
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sidebar button {
display: block;
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
}
.sidebar button:hover {
background: #e8eaf6;
}
.sidebar button.active {
background: #e8eaf6;
border-left: 3px solid #1a237e;
font-weight: 600;
}
.content {
flex: 1;
padding: 2rem;
}
.task-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.task-table th,
.task-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.task-table th {
background: #f5f5f5;
font-weight: 600;
}
.workflow-list {
list-style: none;
margin-top: 1rem;
}
.workflow-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.task-info {
background: white;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin: 1rem 0;
}
.task-info p {
margin: 0.5rem 0;
}
.action-buttons {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.action-buttons button {
padding: 0.5rem 1.5rem;
background: #1a237e;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
.action-buttons button:hover {
background: #283593;
}
.back-btn {
padding: 0.5rem 1rem;
background: none;
border: 1px solid #999;
border-radius: 4px;
cursor: pointer;
margin-bottom: 1rem;
}
.message {
padding: 0.75rem;
background: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 4px;
margin: 1rem 0;
}
button {
padding: 0.5rem 1rem;
background: #1a237e;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #283593;
}
What does this stylesheet do?
It creates a full-height flex layout with a fixed sidebar on the left and a content area on the right. The dark indigo header mirrors Cora SeQuence's branding. Active sidebar items get a left border highlight.
The table, workflow-list, task-info, and action-buttons classes style the components you will build next.
- Save App.css.
You will not see changes yet because the component files are still missing. The next substep creates them.
✔️ Awesome, I've got everything!
Great. Move on to create the component files.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
}
.app {
min-height: 100vh;
}
.header {
background: #1a237e;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.5rem;
}
.user-info {
font-size: 0.9rem;
}
.layout {
display: flex;
min-height: calc(100vh - 60px);
}
.sidebar {
width: 200px;
background: white;
border-right: 1px solid #e0e0e0;
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sidebar button {
display: block;
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 0.95rem;
}
.sidebar button:hover {
background: #e8eaf6;
}
.sidebar button.active {
background: #e8eaf6;
border-left: 3px solid #1a237e;
font-weight: 600;
}
.content {
flex: 1;
padding: 2rem;
}
.task-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.task-table th,
.task-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.task-table th {
background: #f5f5f5;
font-weight: 600;
}
.workflow-list {
list-style: none;
margin-top: 1rem;
}
.workflow-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.task-info {
background: white;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin: 1rem 0;
}
.task-info p {
margin: 0.5rem 0;
}
.action-buttons {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.action-buttons button {
padding: 0.5rem 1.5rem;
background: #1a237e;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
.action-buttons button:hover {
background: #283593;
}
.back-btn {
padding: 0.5rem 1rem;
background: none;
border: 1px solid #999;
border-radius: 4px;
cursor: pointer;
margin-bottom: 1rem;
}
.message {
padding: 0.75rem;
background: #e8f5e9;
border: 1px solid #a5d6a7;
border-radius: 4px;
margin: 1rem 0;
}
button {
padding: 0.5rem 1rem;
background: #1a237e;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #283593;
}
Build the My Tasks and Start Process pages
Each page is a React component that fetches data from your API and renders it. The My Tasks page shows a table of pending tasks. The Start Process page lists available workflow definitions with a button to kick one off.
- Create a components folder inside cora-portal/src/.
- Create a new file called MyTasks.jsx inside src/components/ and paste the following code:
import { useState, useEffect } from 'react';
function MyTasks({ apiBase, currentUser, onViewTask }) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
// Fetch tasks assigned to the current user
useEffect(() => {
fetch(`${apiBase}/api/tasks?assignedTo=${currentUser}`)
.then(res => res.json())
.then(data => {
setTasks(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, [apiBase, currentUser]);
if (loading) return <p>Loading tasks...</p>;
return (
<div>
<h2>My Tasks</h2>
{tasks.length === 0 ? (
<p>No tasks assigned to you.</p>
) : (
<table className="task-table">
<thead>
<tr>
<th>Task</th>
<th>State</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.id}>
<td>{task.name}</td>
<td>{task.state}</td>
<td>{new Date(task.createdAt).toLocaleDateString()}</td>
<td>
<button onClick={() => onViewTask(task.id)}>View</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
export default MyTasks;
What does this component do?
- The useEffect hook calls GET /api/tasks?assignedTo={currentUser} on mount and whenever the user changes.
- Tasks render in a table with columns for name, state, creation date, and a View button.
- Clicking View calls onViewTask(task.id) which triggers the parent App to switch to the detail view.
- Save MyTasks.jsx.
You will not see the app working yet because StartProcess.jsx and TaskDetail.jsx are still missing.
✔️ Awesome, I've got everything!
Great. Create the next component.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
function MyTasks({ apiBase, currentUser, onViewTask }) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${apiBase}/api/tasks?assignedTo=${currentUser}`)
.then(res => res.json())
.then(data => {
setTasks(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, [apiBase, currentUser]);
if (loading) return <p>Loading tasks...</p>;
return (
<div>
<h2>My Tasks</h2>
{tasks.length === 0 ? (
<p>No tasks assigned to you.</p>
) : (
<table className="task-table">
<thead>
<tr>
<th>Task</th>
<th>State</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<tr key={task.id}>
<td>{task.name}</td>
<td>{task.state}</td>
<td>{new Date(task.createdAt).toLocaleDateString()}</td>
<td>
<button onClick={() => onViewTask(task.id)}>View</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
export default MyTasks;
- Create a new file called StartProcess.jsx inside src/components/ and paste the following code:
import { useState, useEffect } from 'react';
function StartProcess({ apiBase, currentUser }) {
const [workflows, setWorkflows] = useState([]);
const [message, setMessage] = useState('');
// Load available workflow definitions
useEffect(() => {
fetch(`${apiBase}/api/workflows`)
.then(res => res.json())
.then(data => setWorkflows(data))
.catch(() => setMessage('Failed to load workflows'));
}, [apiBase]);
const handleStart = async (workflowId) => {
// Start a new process instance
const res = await fetch(`${apiBase}/api/workflows/${workflowId}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startedBy: currentUser })
});
if (res.ok) {
setMessage('Process started successfully!');
} else {
setMessage('Failed to start process.');
}
};
return (
<div>
<h2>Start Process</h2>
{message && <p className="message">{message}</p>}
{workflows.length === 0 ? (
<p>No workflow definitions available.</p>
) : (
<ul className="workflow-list">
{workflows.map(wf => (
<li key={wf.id}>
<div>
<strong>{wf.name}</strong>
<p>{wf.description}</p>
</div>
<button onClick={() => handleStart(wf.id)}>Start</button>
</li>
))}
</ul>
)}
</div>
);
}
export default StartProcess;
What does this component do?
- On mount it fetches all workflow definitions from GET /api/workflows.
- Each workflow renders with its name, description, and a Start button.
- Clicking Start sends a POST /api/workflows/{id}/start request with the current user as startedBy. This triggers the workflow engine to create an instance and assign the first task.
- Save StartProcess.jsx.
One more component to go. The app will still error until TaskDetail.jsx exists.
✔️ Awesome, I've got everything!
Save the file and move on to the final component.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
function StartProcess({ apiBase, currentUser }) {
const [workflows, setWorkflows] = useState([]);
const [message, setMessage] = useState('');
useEffect(() => {
fetch(`${apiBase}/api/workflows`)
.then(res => res.json())
.then(data => setWorkflows(data))
.catch(() => setMessage('Failed to load workflows'));
}, [apiBase]);
const handleStart = async (workflowId) => {
const res = await fetch(`${apiBase}/api/workflows/${workflowId}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startedBy: currentUser })
});
if (res.ok) {
setMessage('Process started successfully!');
} else {
setMessage('Failed to start process.');
}
};
return (
<div>
<h2>Start Process</h2>
{message && <p className="message">{message}</p>}
{workflows.length === 0 ? (
<p>No workflow definitions available.</p>
) : (
<ul className="workflow-list">
{workflows.map(wf => (
<li key={wf.id}>
<div>
<strong>{wf.name}</strong>
<p>{wf.description}</p>
</div>
<button onClick={() => handleStart(wf.id)}>Start</button>
</li>
))}
</ul>
)}
</div>
);
}
export default StartProcess;
Build the Task Detail view with action buttons
The Task Detail view is where workflow state actually advances. When a user clicks an action button (like Approve or Reject), the frontend tells the engine to complete that task with the chosen action. The engine advances the workflow to the next state.
- Create a new file called TaskDetail.jsx inside src/components/ and paste the following code:
import { useState, useEffect } from 'react';
function TaskDetail({ apiBase, taskId, onBack }) {
const [task, setTask] = useState(null);
const [actions, setActions] = useState([]);
const [message, setMessage] = useState('');
// Fetch the task and its available actions
useEffect(() => {
fetch(`${apiBase}/api/tasks/${taskId}`)
.then(res => res.json())
.then(data => {
setTask(data.task);
setActions(data.availableActions);
});
}, [apiBase, taskId]);
const handleAction = async (action) => {
// Complete the task with the chosen action
const res = await fetch(`${apiBase}/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
if (res.ok) {
setMessage(`Task completed with action: ${action}`);
// Navigate back after a short delay
setTimeout(onBack, 1500);
} else {
setMessage('Failed to complete task.');
}
};
if (!task) return <p>Loading...</p>;
return (
<div>
<button className="back-btn" onClick={onBack}>Back to My Tasks</button>
<h2>{task.name}</h2>
<div className="task-info">
<p>State: <strong>{task.state}</strong></p>
<p>Assigned to: <strong>{task.assignedTo}</strong></p>
<p>Created: {new Date(task.createdAt).toLocaleString()}</p>
</div>
{message && <p className="message">{message}</p>}
<div className="action-buttons">
{actions.map(action => (
<button key={action} onClick={() => handleAction(action)}>
{action}
</button>
))}
</div>
</div>
);
}
export default TaskDetail;
What does this component do?
- It fetches the task details and available actions from GET /api/tasks/{id}. The API returns both the task object and the list of valid transitions from the current state.
- Each available action renders as a button. Clicking it sends POST /api/tasks/{id}/complete with the action name.
- After a successful completion, the component shows a confirmation message and navigates back to My Tasks after 1.5 seconds.
- Save TaskDetail.jsx.
- Return to your browser at http://localhost:5173. The error overlay should be gone and you should see the Cora Portal header with a dark indigo background and a sidebar with My Tasks and Start Process.
Still seeing an error overlay?
- Make sure all three component files are inside src/components/ (not directly in src/).
- Check that each filename matches exactly: MyTasks.jsx, StartProcess.jsx, TaskDetail.jsx. File names are case-sensitive.
- Confirm your .NET API is still running in its terminal. The portal needs the backend to respond to fetch requests.
Help me debug my Cora Portal not loading.
✔️ Awesome, I've got everything!
Great. Time to test the full workflow end to end.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
function TaskDetail({ apiBase, taskId, onBack }) {
const [task, setTask] = useState(null);
const [actions, setActions] = useState([]);
const [message, setMessage] = useState('');
useEffect(() => {
fetch(`${apiBase}/api/tasks/${taskId}`)
.then(res => res.json())
.then(data => {
setTask(data.task);
setActions(data.availableActions);
});
}, [apiBase, taskId]);
const handleAction = async (action) => {
const res = await fetch(`${apiBase}/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
if (res.ok) {
setMessage(`Task completed with action: ${action}`);
setTimeout(onBack, 1500);
} else {
setMessage('Failed to complete task.');
}
};
if (!task) return <p>Loading...</p>;
return (
<div>
<button className="back-btn" onClick={onBack}>Back to My Tasks</button>
<h2>{task.name}</h2>
<div className="task-info">
<p>State: <strong>{task.state}</strong></p>
<p>Assigned to: <strong>{task.assignedTo}</strong></p>
<p>Created: {new Date(task.createdAt).toLocaleString()}</p>
</div>
{message && <p className="message">{message}</p>}
<div className="action-buttons">
{actions.map(action => (
<button key={action} onClick={() => handleAction(action)}>
{action}
</button>
))}
</div>
</div>
);
}
export default TaskDetail;
Now test the full end-to-end workflow to confirm everything connects.
- Click Start Process in the sidebar.
- Click Start next to the Leave Request workflow. You should see "Process started successfully!" appear.
- Click My Tasks in the sidebar. You should see a task appear in the table (assigned to the requester role or manager role depending on your workflow definition).
- Click View on the task. You should see the task detail page with action buttons (like Approve or Submit).
- Click one of the action buttons. You should see "Task completed with action: ..." and then the app returns to My Tasks.
- Confirm the completed task has disappeared from the list and the workflow has advanced.
What just happened?
You ran a complete workflow cycle. Starting the process created an instance in the database at the initial state and assigned the first task. Completing that task advanced the instance to the next state and either created a new task or marked the workflow as complete.
This is the exact pattern that enterprise BPM engines like Cora SeQuence use at scale. The difference is volume, permissions, and audit logging.
Tasks not appearing after starting a process?
- Check that the assignedTo value on the task matches what you are querying. The My Tasks page filters by currentUser which is requester. If the first task is assigned to manager, it will not appear for requester.
- Open your browser's developer tools (F12), go to the Network tab, and check the API response from /api/tasks. Confirm the response is not empty and the assignedTo field matches your user.
- Make sure the CORS policy in Program.cs allows http://localhost:5173. If you see CORS errors in the console, the API is blocking the request.
Help me debug why tasks are not showing up in My Tasks.
Secret mission
Add Role-Based Task Assignment
Right now every task is assigned to a generic role string like 'manager' or 'requester'. In a real BPM system like Cora SeQuence, the engine resolves those roles to actual users. Can you add a role-resolution layer and a user switcher so Bob and Alice each see only their own tasks?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- A local SQL Server database called CoraEngine (free, stored on your machine).
- The CoraEngine backend project folder.
- The cora-portal frontend project folder.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- Both projects run locally with no ongoing cost.
- Your SQL Server database persists on your machine and takes minimal disk space.
- The dev servers only consume resources while actively running.
Pause - I'll come back to this later
Shut down any running dev servers to free up memory. Your files and database persist automatically.
- In the terminal running the .NET API, press Ctrl+C to stop the server.
- In the terminal running the Vite dev server, press Ctrl+C to stop it.
- Your CoraEngine database stays on your SQL Server instance. No data is lost.
- To resume later, run dotnet run in the CoraEngine folder and npm run dev in the cora-portal folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database, code, and all workflow data permanently.
- Drop the database by opening SQL Server Management Studio, right-clicking the CoraEngine database in the Object Explorer, selecting Delete, checking Close existing connections, and clicking OK.
- Delete both project folders by running these commands in the terminal (adjust the path if you created them somewhere other than your Desktop):
macOS
cd ~/Desktop
rm -rf CoraEngine
rm -rf cora-portal
Windows
cd ~\Desktop
rmdir /s /q CoraEngine
rmdir /s /q cora-portal
- Confirm both folders are gone by running ls (macOS) or dir (Windows) and verifying neither CoraEngine nor cora-portal appears in the listing.
Nice Work!
Nice Work!
You just built a fully functional BPM workflow engine and task portal from scratch. You now understand the core architecture pattern behind enterprise iBPMS tools like Cora SeQuence.
You've learned how to:
- Build a state machine workflow engine in C# that stores workflow definitions as JSON, creates running instances, and routes tasks to users based on transitions.
- Design a SQL Server database schema with Entity Framework Core migrations to store workflow definitions, instances, and tasks separately.
- Connect a React task portal to an ASP.NET Core REST API, letting users start processes, view assigned tasks, and complete them with action buttons.
- Secret Mission: Added role-based task routing so tasks automatically appear in the correct user's queue based on workflow transition roles.
Ready to quiz yourself?