From 79bcdbd342841db70e9941f78bd2bfaa151bb266 Mon Sep 17 00:00:00 2001 From: Dmitry Grigorev Date: Fri, 13 Mar 2026 16:46:52 +0300 Subject: [PATCH] Added examples and fixed unneccessary reference requirement --- TaskPipeline.sln | 25 +- examples/ClaimFlow/ClaimFlow.sln | 27 + examples/ClaimFlow/Directory.Build.props | 8 + examples/ClaimFlow/README.md | 56 +++ .../src/ClaimFlow.Web/ClaimFlow.Web.csproj | 18 + .../Controllers/HomeController.cs | 122 +++++ .../src/ClaimFlow.Web/Data/AppDbContext.cs | 56 +++ .../src/ClaimFlow.Web/Domain/ClaimCase.cs | 46 ++ .../Domain/ClaimExecutionSnapshot.cs | 14 + .../src/ClaimFlow.Web/Domain/Enums.cs | 32 ++ .../src/ClaimFlow.Web/Domain/PolicyRecord.cs | 17 + .../Models/ClaimProcessingContext.cs | 35 ++ .../src/ClaimFlow.Web/Models/ClaimRequest.cs | 28 ++ .../Pipeline/ExecutionSummaryMapper.cs | 98 ++++ .../Pipeline/ExecutionSummaryModels.cs | 21 + .../Pipeline/PipelineContracts.cs | 24 + .../ClaimFlow/src/ClaimFlow.Web/Program.cs | 46 ++ .../Properties/launchSettings.json | 12 + .../Services/ClaimPipelineFactory.cs | 262 ++++++++++ .../Services/ClaimProcessingService.cs | 62 +++ .../ClaimFlow.Web/Services/Implementations.cs | 253 ++++++++++ .../src/ClaimFlow.Web/Services/Interfaces.cs | 66 +++ .../ViewModels/HomeViewModels.cs | 38 ++ .../ClaimFlow.Web/Views/Home/Details.cshtml | 107 ++++ .../src/ClaimFlow.Web/Views/Home/Error.cshtml | 11 + .../src/ClaimFlow.Web/Views/Home/Index.cshtml | 140 ++++++ .../ClaimFlow.Web/Views/Home/NewClaim.cshtml | 103 ++++ .../Views/Shared/_ExecutionNode.cshtml | 58 +++ .../ClaimFlow.Web/Views/Shared/_Layout.cshtml | 45 ++ .../Shared/_ValidationScriptsPartial.cshtml | 3 + .../ClaimFlow.Web/Views/_ViewImports.cshtml | 5 + .../src/ClaimFlow.Web/Views/_ViewStart.cshtml | 3 + .../src/ClaimFlow.Web/appsettings.json | 12 + .../ClaimFlow/src/ClaimFlow.Web/claimflow.db | Bin 0 -> 4096 bytes .../src/ClaimFlow.Web/claimflow.db-shm | Bin 0 -> 32768 bytes .../src/ClaimFlow.Web/claimflow.db-wal | Bin 0 -> 49472 bytes .../src/ClaimFlow.Web/wwwroot/css/site.css | 468 ++++++++++++++++++ .../ClaimFlow.Web.Tests.csproj | 19 + .../ClaimProcessingServiceTests.cs | 118 +++++ .../Abstractions/BranchExecutionMode.cs | 10 + .../Abstractions/ExecutionStatus.cs | 12 + .../Abstractions/IBranchMergeStrategy.cs | 18 + src/TaskPipeline/Abstractions/IPipeline.cs | 20 + .../Abstractions/IPipelineBehavior.cs | 17 + .../Abstractions/IPipelineCondition.cs | 18 + .../Abstractions/IPipelineNode.cs | 23 + .../Abstractions/IPipelineStep.cs | 18 + .../Abstractions/NodeExecutionResult.cs | 73 +++ src/TaskPipeline/Abstractions/NodeKind.cs | 14 + .../Abstractions/PipelineExecutionResult.cs | 95 ++++ .../Abstractions/PipelineFailureMode.cs | 10 + .../PipelineNodeExecutionContext.cs | 11 + .../Abstractions/PipelineOptions.cs | 12 + src/TaskPipeline/TaskPipeline.csproj | 5 +- .../TaskPipeline.Tests.csproj | 1 - 55 files changed, 2803 insertions(+), 12 deletions(-) create mode 100644 examples/ClaimFlow/ClaimFlow.sln create mode 100644 examples/ClaimFlow/Directory.Build.props create mode 100644 examples/ClaimFlow/README.md create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/ClaimFlow.Web.csproj create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Controllers/HomeController.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Data/AppDbContext.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimCase.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimExecutionSnapshot.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Domain/Enums.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Domain/PolicyRecord.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimProcessingContext.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimRequest.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryMapper.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryModels.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/PipelineContracts.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Program.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Properties/launchSettings.json create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimPipelineFactory.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimProcessingService.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Services/Implementations.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Services/Interfaces.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/ViewModels/HomeViewModels.cs create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Details.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Error.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Index.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/NewClaim.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ExecutionNode.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_Layout.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ValidationScriptsPartial.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewImports.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewStart.cshtml create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/appsettings.json create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db-shm create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db-wal create mode 100644 examples/ClaimFlow/src/ClaimFlow.Web/wwwroot/css/site.css create mode 100644 examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimFlow.Web.Tests.csproj create mode 100644 examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimProcessingServiceTests.cs create mode 100644 src/TaskPipeline/Abstractions/BranchExecutionMode.cs create mode 100644 src/TaskPipeline/Abstractions/ExecutionStatus.cs create mode 100644 src/TaskPipeline/Abstractions/IBranchMergeStrategy.cs create mode 100644 src/TaskPipeline/Abstractions/IPipeline.cs create mode 100644 src/TaskPipeline/Abstractions/IPipelineBehavior.cs create mode 100644 src/TaskPipeline/Abstractions/IPipelineCondition.cs create mode 100644 src/TaskPipeline/Abstractions/IPipelineNode.cs create mode 100644 src/TaskPipeline/Abstractions/IPipelineStep.cs create mode 100644 src/TaskPipeline/Abstractions/NodeExecutionResult.cs create mode 100644 src/TaskPipeline/Abstractions/NodeKind.cs create mode 100644 src/TaskPipeline/Abstractions/PipelineExecutionResult.cs create mode 100644 src/TaskPipeline/Abstractions/PipelineFailureMode.cs create mode 100644 src/TaskPipeline/Abstractions/PipelineNodeExecutionContext.cs create mode 100644 src/TaskPipeline/Abstractions/PipelineOptions.cs diff --git a/TaskPipeline.sln b/TaskPipeline.sln index 4330b26..0f7ec19 100644 --- a/TaskPipeline.sln +++ b/TaskPipeline.sln @@ -2,11 +2,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskPipeline.Abstractions", "src/TaskPipeline.Abstractions/TaskPipeline.Abstractions.csproj", "{11111111-1111-1111-1111-111111111111}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskPipeline", "src\TaskPipeline\TaskPipeline.csproj", "{22222222-2222-2222-2222-222222222222}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskPipeline", "src/TaskPipeline/TaskPipeline.csproj", "{22222222-2222-2222-2222-222222222222}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskPipeline.Tests", "tests\TaskPipeline.Tests\TaskPipeline.Tests.csproj", "{33333333-3333-3333-3333-333333333333}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskPipeline.Tests", "tests/TaskPipeline.Tests/TaskPipeline.Tests.csproj", "{33333333-3333-3333-3333-333333333333}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClaimFlow.Web", "examples\ClaimFlow\src\ClaimFlow.Web\ClaimFlow.Web.csproj", "{47198912-FB8A-99BD-4642-C2727B26C921}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -14,10 +16,6 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU - {11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU - {11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU {22222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -26,5 +24,18 @@ Global {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU {33333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU {33333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {47198912-FB8A-99BD-4642-C2727B26C921}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47198912-FB8A-99BD-4642-C2727B26C921}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47198912-FB8A-99BD-4642-C2727B26C921}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47198912-FB8A-99BD-4642-C2727B26C921}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {47198912-FB8A-99BD-4642-C2727B26C921} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {38D337CA-80CF-401B-988C-33ADFCD6666B} EndGlobalSection EndGlobal diff --git a/examples/ClaimFlow/ClaimFlow.sln b/examples/ClaimFlow/ClaimFlow.sln new file mode 100644 index 0000000..d456b33 --- /dev/null +++ b/examples/ClaimFlow/ClaimFlow.sln @@ -0,0 +1,27 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClaimFlow.Web", "src\ClaimFlow.Web\ClaimFlow.Web.csproj", "{7A1C0EA2-5E40-4D04-87F5-39F8B44F0101}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClaimFlow.Web.Tests", "tests\ClaimFlow.Web.Tests\ClaimFlow.Web.Tests.csproj", "{3DA3871D-3D8E-4FE1-9E50-A85D4CB95E56}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7A1C0EA2-5E40-4D04-87F5-39F8B44F0101}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A1C0EA2-5E40-4D04-87F5-39F8B44F0101}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A1C0EA2-5E40-4D04-87F5-39F8B44F0101}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A1C0EA2-5E40-4D04-87F5-39F8B44F0101}.Release|Any CPU.Build.0 = Release|Any CPU + {3DA3871D-3D8E-4FE1-9E50-A85D4CB95E56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3DA3871D-3D8E-4FE1-9E50-A85D4CB95E56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3DA3871D-3D8E-4FE1-9E50-A85D4CB95E56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3DA3871D-3D8E-4FE1-9E50-A85D4CB95E56}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/examples/ClaimFlow/Directory.Build.props b/examples/ClaimFlow/Directory.Build.props new file mode 100644 index 0000000..313c144 --- /dev/null +++ b/examples/ClaimFlow/Directory.Build.props @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + latest + + diff --git a/examples/ClaimFlow/README.md b/examples/ClaimFlow/README.md new file mode 100644 index 0000000..3d8676e --- /dev/null +++ b/examples/ClaimFlow/README.md @@ -0,0 +1,56 @@ +# ClaimFlow + +ClaimFlow is a compact ASP.NET Core MVC sample that demonstrates how to use the `TaskPipeline` NuGet package in a realistic business workflow. + +## Scenario + +The app processes insurance claims and shows why a strongly typed orchestration library is useful when a workflow has: + +- ordered steps; +- business conditions; +- parallel branches; +- explicit merge logic; +- cancellation support; +- partial failure handling; +- execution auditing. + +## Stack + +- ASP.NET Core MVC (.NET 8) +- SQLite via EF Core +- TaskPipeline from NuGet +- Bootstrap-based UI +- xUnit tests + +## What the sample demonstrates + +- sequential pipeline nodes; +- `AddConditional` for true/false business branches; +- `AddFork` with named parallel branches; +- `DelegateMergeStrategy` for aggregation after fork completion; +- `ContinueOnError` failure mode; +- persisted execution snapshots; +- a simple dashboard for claims and execution details. + +## Project layout + +```text +src/ + ClaimFlow.Web/ +tests/ + ClaimFlow.Web.Tests/ +``` + +## Run + +```bash +dotnet restore +dotnet build +dotnet run --project src/ClaimFlow.Web +``` + +Then open the local URL printed by ASP.NET Core. + +## Notes + +The sample uses mock integrations for anti-fraud, repair quotes, policy lookup, payment pre-checks, and notifications so the application can run locally with SQLite only. diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/ClaimFlow.Web.csproj b/examples/ClaimFlow/src/ClaimFlow.Web/ClaimFlow.Web.csproj new file mode 100644 index 0000000..5668edb --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/ClaimFlow.Web.csproj @@ -0,0 +1,18 @@ + + + ClaimFlow.Web + ClaimFlow.Web + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Controllers/HomeController.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Controllers/HomeController.cs new file mode 100644 index 0000000..aa6a4d6 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Controllers/HomeController.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using ClaimFlow.Web.Data; +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Models; +using ClaimFlow.Web.Pipeline; +using ClaimFlow.Web.Services; +using ClaimFlow.Web.ViewModels; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ClaimFlow.Web.Controllers; + +public sealed class HomeController( + IClaimRepository claimRepository, + ClaimProcessingService claimProcessingService, + AppDbContext dbContext, + ILogger logger) : Controller +{ + [HttpGet] + public async Task Index(CancellationToken cancellationToken) + { + var claims = await claimRepository.ListAsync(cancellationToken); + var policies = await dbContext.Policies.AsNoTracking().OrderBy(x => x.PolicyNumber).ToListAsync(cancellationToken); + + var model = new DashboardViewModel + { + Claims = claims.Select(x => new ClaimListItemViewModel + { + Id = x.Id, + CustomerName = x.CustomerName, + PolicyNumber = x.PolicyNumber, + ClaimType = x.ClaimType, + Status = x.Status, + FinalDecisionType = x.FinalDecisionType, + ApprovedPayoutAmount = x.ApprovedPayoutAmount, + CreatedAtUtc = x.CreatedAtUtc + }).ToList(), + Policies = policies.Select(x => new DemoPolicyViewModel + { + PolicyNumber = x.PolicyNumber, + IsActive = x.IsActive, + CoverageLimit = x.CoverageLimit, + SupportsAutoPayment = x.SupportsAutoPayment + }).ToList() + }; + + return View(model); + } + + [HttpGet] + public IActionResult NewClaim() + { + return View(new ClaimRequest + { + ClaimType = ClaimType.Kasko, + HasPhotos = true + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task NewClaim(ClaimRequest request, CancellationToken cancellationToken) + { + if (!ModelState.IsValid) + { + return View(request); + } + + try + { + var result = await claimProcessingService.ProcessAsync(request, cancellationToken); + TempData["Flash"] = $"Claim {result.Claim.Id} processed with status {result.Claim.Status}."; + return RedirectToAction(nameof(Details), new { id = result.Claim.Id }); + } + catch (OperationCanceledException) + { + TempData["Flash"] = "Claim processing was cancelled."; + return RedirectToAction(nameof(Index)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to process claim."); + ModelState.AddModelError(string.Empty, exception.Message); + return View(request); + } + } + + [HttpGet] + public async Task Details(Guid id, CancellationToken cancellationToken) + { + var claim = await claimRepository.GetAsync(id, cancellationToken); + if (claim is null) + { + return NotFound(); + } + + var latestExecution = claim.Executions.OrderByDescending(x => x.CreatedAtUtc).FirstOrDefault(); + var summary = latestExecution is null + ? null + : JsonSerializer.Deserialize(latestExecution.SummaryJson); + + var model = new ClaimDetailsViewModel + { + Claim = claim, + ExecutionSummary = summary, + MissingDocuments = ParseJsonArray(claim.MissingDocumentsJson), + Warnings = ParseJsonArray(claim.WarningsJson) + }; + + return View(model); + } + + + [HttpGet] + public IActionResult Error() + { + return View(); + } + + private static List ParseJsonArray(string json) + => JsonSerializer.Deserialize>(json) ?? []; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Data/AppDbContext.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Data/AppDbContext.cs new file mode 100644 index 0000000..57d6208 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Data/AppDbContext.cs @@ -0,0 +1,56 @@ +using ClaimFlow.Web.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ClaimFlow.Web.Data; + +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Claims => Set(); + public DbSet Executions => Set(); + public DbSet Policies => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(x => x.Executions) + .WithOne(x => x.ClaimCase) + .HasForeignKey(x => x.ClaimCaseId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(x => x.PolicyNumber) + .IsUnique(); + + modelBuilder.Entity().HasData( + new PolicyRecord + { + Id = 1, + PolicyNumber = "KSK-1001", + IsActive = true, + CoverageLimit = 7000m, + CoversGlass = true, + CoversTowTruck = true, + SupportsAutoPayment = true + }, + new PolicyRecord + { + Id = 2, + PolicyNumber = "OSG-2001", + IsActive = true, + CoverageLimit = 2500m, + CoversGlass = false, + CoversTowTruck = false, + SupportsAutoPayment = true + }, + new PolicyRecord + { + Id = 3, + PolicyNumber = "KSK-9000", + IsActive = false, + CoverageLimit = 10000m, + CoversGlass = true, + CoversTowTruck = true, + SupportsAutoPayment = false + }); + } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimCase.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimCase.cs new file mode 100644 index 0000000..4f7c20c --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimCase.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace ClaimFlow.Web.Domain; + +public sealed class ClaimCase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(128)] + public string CustomerName { get; set; } = string.Empty; + + [MaxLength(128)] + public string CustomerEmail { get; set; } = string.Empty; + + [MaxLength(32)] + public string PolicyNumber { get; set; } = string.Empty; + + public ClaimType ClaimType { get; set; } + public ClaimStatus Status { get; set; } = ClaimStatus.Draft; + public FinalDecisionType FinalDecisionType { get; set; } = FinalDecisionType.None; + + [MaxLength(1024)] + public string Description { get; set; } = string.Empty; + + public decimal EstimatedDamageAmount { get; set; } + public decimal ApprovedPayoutAmount { get; set; } + public bool HasPoliceReport { get; set; } + public bool HasPhotos { get; set; } + public bool RequiresTowTruck { get; set; } + public bool InjuryInvolved { get; set; } + public bool IsVipCustomer { get; set; } + + [MaxLength(2048)] + public string MissingDocumentsJson { get; set; } = "[]"; + + [MaxLength(2048)] + public string WarningsJson { get; set; } = "[]"; + + [MaxLength(4096)] + public string FinalDecisionReason { get; set; } = string.Empty; + + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime? ProcessedAtUtc { get; set; } + + public ICollection Executions { get; set; } = new List(); +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimExecutionSnapshot.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimExecutionSnapshot.cs new file mode 100644 index 0000000..ff20174 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/ClaimExecutionSnapshot.cs @@ -0,0 +1,14 @@ +namespace ClaimFlow.Web.Domain; + +public sealed class ClaimExecutionSnapshot +{ + public int Id { get; set; } + public Guid ClaimCaseId { get; set; } + public ClaimCase ClaimCase { get; set; } = default!; + + public string PipelineName { get; set; } = string.Empty; + public string PipelineStatus { get; set; } = string.Empty; + public double DurationMs { get; set; } + public string SummaryJson { get; set; } = "{}"; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Domain/Enums.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/Enums.cs new file mode 100644 index 0000000..0f4e79d --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/Enums.cs @@ -0,0 +1,32 @@ +namespace ClaimFlow.Web.Domain; + +public enum ClaimType +{ + Unknown = 0, + Osago = 1, + Kasko = 2 +} + +public enum ClaimStatus +{ + Draft = 0, + Received = 1, + Validating = 2, + Assessing = 3, + WaitingForDocuments = 4, + ManualReview = 5, + Approved = 6, + Rejected = 7, + Paid = 8, + Failed = 9, + Cancelled = 10 +} + +public enum FinalDecisionType +{ + None = 0, + Approve = 1, + Reject = 2, + RequestDocuments = 3, + ManualReview = 4 +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Domain/PolicyRecord.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/PolicyRecord.cs new file mode 100644 index 0000000..229534c --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Domain/PolicyRecord.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace ClaimFlow.Web.Domain; + +public sealed class PolicyRecord +{ + public int Id { get; set; } + + [MaxLength(32)] + public string PolicyNumber { get; set; } = string.Empty; + + public bool IsActive { get; set; } + public decimal CoverageLimit { get; set; } + public bool CoversGlass { get; set; } + public bool CoversTowTruck { get; set; } + public bool SupportsAutoPayment { get; set; } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimProcessingContext.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimProcessingContext.cs new file mode 100644 index 0000000..b35ceff --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimProcessingContext.cs @@ -0,0 +1,35 @@ +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Pipeline; + +namespace ClaimFlow.Web.Models; + +public sealed class ClaimProcessingContext +{ + public Guid ClaimId { get; init; } + public ClaimRequest Request { get; init; } = default!; + public ClaimCase ClaimCase { get; init; } = default!; + + public bool IsValid { get; set; } + public bool IsComplete { get; set; } + public bool RequiresManualReview { get; set; } + public bool IsHighRisk { get; set; } + public bool IsEligibleForAutoApproval { get; set; } + + public PolicySnapshot? Policy { get; set; } + public FraudCheckResult? FraudCheck { get; set; } + public CoverageCheckResult? CoverageCheck { get; set; } + public DocumentVerificationResult? DocumentVerification { get; set; } + public DamageEstimateResult? DamageEstimate { get; set; } + public RepairQuoteResult? RepairQuote { get; set; } + public PaymentPrecheckResult? PaymentPrecheck { get; set; } + + public List MissingDocuments { get; } = []; + public List Notifications { get; } = []; + public List AuditTrail { get; } = []; + public List Warnings { get; } = []; + + public FinalDecision? FinalDecision { get; set; } + + public void AddAudit(string message) => AuditTrail.Add(message); + public void AddWarning(string message) => Warnings.Add(message); +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimRequest.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimRequest.cs new file mode 100644 index 0000000..6f09c5b --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Models/ClaimRequest.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using ClaimFlow.Web.Domain; + +namespace ClaimFlow.Web.Models; + +public sealed class ClaimRequest +{ + [Required, StringLength(128)] + public string CustomerName { get; set; } = string.Empty; + + [Required, EmailAddress, StringLength(128)] + public string CustomerEmail { get; set; } = string.Empty; + + [Required, StringLength(32)] + public string PolicyNumber { get; set; } = string.Empty; + + [Required] + public ClaimType ClaimType { get; set; } + + [Required, StringLength(1024, MinimumLength = 10)] + public string Description { get; set; } = string.Empty; + + public bool HasPoliceReport { get; set; } + public bool HasPhotos { get; set; } + public bool RequiresTowTruck { get; set; } + public bool InjuryInvolved { get; set; } + public bool IsVipCustomer { get; set; } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryMapper.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryMapper.cs new file mode 100644 index 0000000..bfb231d --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryMapper.cs @@ -0,0 +1,98 @@ +using System.Collections; +using System.Reflection; +using TaskPipeline.Abstractions; + +namespace ClaimFlow.Web.Pipeline; + +public static class ExecutionSummaryMapper +{ + public static ExecutionSummary Map(PipelineExecutionResult result) + { + return new ExecutionSummary + { + Status = result.Status.ToString(), + DurationMs = result.Duration.TotalMilliseconds, + Root = MapNode(result.Root), + FailedNodes = result.FailedNodes.Select(MapNode).ToList(), + CancelledNodes = result.CancelledNodes.Select(MapNode).ToList() + }; + } + + private static ExecutionNodeSummary MapNode(object node) + { + return new ExecutionNodeSummary + { + Name = Read(node, "Name") ?? Read(node, "NodeName") ?? "node", + Type = Read(node, "NodeType") ?? node.GetType().Name, + Status = ReadValue(node, "Status")?.ToString() ?? "Unknown", + DurationMs = Read(node, "Duration").TotalMilliseconds, + Exception = ReadException(node), + Metadata = ReadMetadata(node), + Children = ReadChildren(node) + }; + } + + private static List ReadChildren(object node) + { + var children = ReadValue(node, "Children") as IEnumerable; + if (children is null) + { + return []; + } + + var list = new List(); + foreach (var child in children) + { + if (child is not null) + { + list.Add(MapNode(child)); + } + } + + return list; + } + + private static Dictionary ReadMetadata(object node) + { + var metadata = ReadValue(node, "Metadata") as IEnumerable; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (metadata is null) + { + return result; + } + + foreach (var item in metadata) + { + if (item is null) + { + continue; + } + + var key = Read(item, "Key"); + var value = ReadValue(item, "Value")?.ToString(); + + if (!string.IsNullOrWhiteSpace(key)) + { + result[key] = value ?? string.Empty; + } + } + + return result; + } + + private static string? ReadException(object node) + => (ReadValue(node, "Exception") as Exception)?.Message ?? ReadValue(node, "Exception")?.ToString(); + + private static T? Read(object target, string propertyName) + { + var value = ReadValue(target, propertyName); + return value is T typed ? typed : default; + } + + private static object? ReadValue(object target, string propertyName) + { + var property = target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + return property?.GetValue(target); + } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryModels.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryModels.cs new file mode 100644 index 0000000..866a9d2 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/ExecutionSummaryModels.cs @@ -0,0 +1,21 @@ +namespace ClaimFlow.Web.Pipeline; + +public sealed class ExecutionSummary +{ + public string Status { get; set; } = string.Empty; + public double DurationMs { get; set; } + public List FailedNodes { get; set; } = []; + public List CancelledNodes { get; set; } = []; + public ExecutionNodeSummary? Root { get; set; } +} + +public sealed class ExecutionNodeSummary +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public double DurationMs { get; set; } + public string? Exception { get; set; } + public Dictionary Metadata { get; set; } = []; + public List Children { get; set; } = []; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/PipelineContracts.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/PipelineContracts.cs new file mode 100644 index 0000000..341a7f2 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Pipeline/PipelineContracts.cs @@ -0,0 +1,24 @@ +using ClaimFlow.Web.Domain; + +namespace ClaimFlow.Web.Pipeline; + +public sealed record FinalDecision( + FinalDecisionType Type, + string Reason, + decimal? ApprovedAmount = null); + +public sealed record PolicySnapshot( + string PolicyNumber, + bool IsActive, + decimal CoverageLimit, + bool CoversGlass, + bool CoversTowTruck, + bool SupportsAutoPayment); + +public sealed record FraudCheckResult(double Score, bool RequiresManualReview, string Summary); +public sealed record CoverageCheckResult(bool IsCovered, string Reason); +public sealed record DocumentCompletenessResult(bool IsComplete, IReadOnlyList MissingDocuments); +public sealed record DocumentVerificationResult(bool IsValid, IReadOnlyList MissingDocuments); +public sealed record DamageEstimateResult(decimal Amount, string Summary); +public sealed record RepairQuoteResult(decimal Amount, string PartnerName); +public sealed record PaymentPrecheckResult(bool CanPayAutomatically, string Reason); diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Program.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Program.cs new file mode 100644 index 0000000..1f29a94 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Program.cs @@ -0,0 +1,46 @@ +using ClaimFlow.Web.Data; +using ClaimFlow.Web.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllersWithViews(); + +builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Data Source=claimflow.db")); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Home/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +await app.RunAsync(); diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Properties/launchSettings.json b/examples/ClaimFlow/src/ClaimFlow.Web/Properties/launchSettings.json new file mode 100644 index 0000000..a004e5b --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ClaimFlow.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:56206;http://localhost:56207" + } + } +} \ No newline at end of file diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimPipelineFactory.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimPipelineFactory.cs new file mode 100644 index 0000000..a8aa0e6 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimPipelineFactory.cs @@ -0,0 +1,262 @@ +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Models; +using ClaimFlow.Web.Pipeline; +using TaskPipeline; +using TaskPipeline.Abstractions; + +namespace ClaimFlow.Web.Services; + +public sealed class ClaimPipelineFactory( + IClaimValidationService validationService, + IPolicyService policyService, + IFraudService fraudService, + IDocumentService documentService, + IDamageEstimator damageEstimator, + IRepairQuoteService repairQuoteService, + IPaymentService paymentService, + INotificationService notificationService, + IClaimRepository claimRepository) : IClaimPipelineFactory +{ + public IPipeline Create() + { + return PipelineBuilder + .Create("claim-processing") + .Configure(new PipelineOptions + { + FailureMode = PipelineFailureMode.ContinueOnError + }) + .AddStep("mark-validating", ctx => + { + ctx.ClaimCase.Status = ClaimStatus.Validating; + ctx.AddAudit("Claim moved to validation stage."); + }) + .AddStep("validate-request", async (ctx, ct) => + { + ctx.IsValid = await validationService.ValidateAsync(ctx.Request, ct); + if (!ctx.IsValid) + { + throw new InvalidOperationException("Claim request is invalid."); + } + + ctx.AddAudit("Claim request validated."); + }) + .AddStep("attach-policy", async (ctx, ct) => + { + ctx.Policy = await policyService.GetPolicyAsync(ctx.Request.PolicyNumber, ct) + ?? throw new InvalidOperationException("Policy not found."); + + ctx.AddAudit($"Policy attached: {ctx.Policy.PolicyNumber}"); + }) + .AddConditional( + "check-policy-found", + (ctx, _) => ValueTask.FromResult(ctx.Policy is not null), + whenTrue: branch => branch + .AddConditional( + "check-policy-active", + (ctx, _) => ValueTask.FromResult(ctx.Policy?.IsActive == true), + whenTrue: b => b.AddStep("policy-active-log", ctx => + { + ctx.AddAudit("Policy is active."); + }), + whenFalse: b => b.AddStep("reject-inactive-policy", ctx => + { + ctx.FinalDecision = new FinalDecision( + FinalDecisionType.Reject, + "Policy is inactive."); + + ctx.ClaimCase.Status = ClaimStatus.Rejected; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.Reject; + ctx.ClaimCase.FinalDecisionReason = "Policy is inactive"; + ctx.AddAudit("Claim rejected because policy is inactive."); + })), + whenFalse: branch => branch.AddStep("reject-policy-not-found", ctx => + { + ctx.FinalDecision = new FinalDecision( + FinalDecisionType.Reject, + "Policy not found."); + + ctx.ClaimCase.Status = ClaimStatus.Rejected; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.Reject; + ctx.ClaimCase.FinalDecisionReason = "Policy not found"; + ctx.AddAudit("Claim rejected because policy was not found."); + })) + .AddConditional( + "check-policy-active", + (ctx, _) => ValueTask.FromResult(ctx.Policy?.IsActive == true), + whenTrue: branch => branch.AddStep("policy-active-log", ctx => ctx.AddAudit("Policy is active.")), + whenFalse: branch => branch.AddStep("reject-inactive-policy", ctx => + { + ctx.FinalDecision = new FinalDecision(FinalDecisionType.Reject, "Policy is inactive."); + ctx.ClaimCase.Status = ClaimStatus.Rejected; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.Reject; + ctx.ClaimCase.FinalDecisionReason = "Policy is inactive."; + ctx.AddAudit("Claim rejected because policy is inactive."); + })) + .AddConditional( + "check-completeness", + async (ctx, ct) => + { + var result = await documentService.CheckCompletenessAsync(ctx.Request, ct); + ctx.IsComplete = result.IsComplete; + ctx.MissingDocuments.Clear(); + ctx.MissingDocuments.AddRange(result.MissingDocuments); + return ctx.IsComplete; + }, + whenTrue: branch => branch.AddStep("mark-assessing", ctx => + { + ctx.ClaimCase.Status = ClaimStatus.Assessing; + ctx.AddAudit("Claim package is complete."); + }), + whenFalse: branch => branch.AddStep("request-missing-documents", async (ctx, ct) => + { + ctx.FinalDecision = new FinalDecision(FinalDecisionType.RequestDocuments, "Missing required documents."); + ctx.ClaimCase.Status = ClaimStatus.WaitingForDocuments; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.RequestDocuments; + ctx.ClaimCase.FinalDecisionReason = "Missing required documents."; + + await notificationService.RequestMissingDocumentsAsync(ctx.ClaimId, ctx.MissingDocuments, ct); + ctx.AddAudit("Missing documents requested from customer."); + })) + .AddConditional( + "continue-only-when-complete", + (ctx, _) => ValueTask.FromResult(ctx.IsComplete), + whenTrue: branch => branch + .AddFork( + "parallel-assessment", + fork => fork + .AddBranch("fraud-check", pipeline => pipeline.AddStep("run-fraud-check", async (ctx, ct) => + { + ctx.FraudCheck = await fraudService.CheckAsync(ctx.Request, ct); + ctx.IsHighRisk = ctx.FraudCheck.RequiresManualReview; + ctx.AddAudit($"Fraud score: {ctx.FraudCheck.Score:0.00}"); + })) + .AddBranch("coverage-check", pipeline => pipeline.AddStep("run-coverage-check", async (ctx, ct) => + { + ctx.CoverageCheck = await policyService.CheckCoverageAsync(ctx.Policy!, ctx.Request, ct); + ctx.AddAudit($"Coverage result: {ctx.CoverageCheck.IsCovered}"); + })) + .AddBranch("document-verification", pipeline => pipeline.AddStep("verify-documents", async (ctx, ct) => + { + ctx.DocumentVerification = await documentService.VerifyAsync(ctx.Request, ct); + if (!ctx.DocumentVerification.IsValid) + { + foreach (var item in ctx.DocumentVerification.MissingDocuments) + { + if (!ctx.MissingDocuments.Contains(item, StringComparer.OrdinalIgnoreCase)) + { + ctx.MissingDocuments.Add(item); + } + } + } + + ctx.AddAudit("Documents verified."); + })) + .AddBranch("damage-estimation", pipeline => pipeline.AddStep("estimate-damage", async (ctx, ct) => + { + ctx.DamageEstimate = await damageEstimator.EstimateAsync(ctx.Request, ct); + ctx.ClaimCase.EstimatedDamageAmount = ctx.DamageEstimate.Amount; + ctx.AddAudit($"Estimated damage amount: {ctx.ClaimCase.EstimatedDamageAmount:0.00}"); + })) + .AddBranch("repair-quote", pipeline => pipeline.AddStep("get-repair-quote", async (ctx, ct) => + { + ctx.RepairQuote = await repairQuoteService.GetBestQuoteAsync(ctx.Request, ct); + ctx.AddAudit($"Repair quote received: {ctx.RepairQuote.Amount:0.00}"); + })) + .AddBranch("payment-precheck", pipeline => pipeline.AddStep("run-payment-precheck", async (ctx, ct) => + { + ctx.PaymentPrecheck = await paymentService.PrecheckAsync(ctx.Request, ct); + ctx.AddAudit($"Payment precheck: {ctx.PaymentPrecheck.CanPayAutomatically}"); + })) + .AddBranch("customer-notification", pipeline => pipeline.AddStep("notify-processing-started", async (ctx, ct) => + { + await notificationService.NotifyClaimInProgressAsync(ctx.ClaimId, ct); + ctx.Notifications.Add("Processing started notification sent."); + })), + executionMode: BranchExecutionMode.Parallel, + mergeStrategy: new DelegateMergeStrategy( + "build-final-decision", + async (ctx, _, ct) => + { + if (ctx.CoverageCheck is { IsCovered: false }) + { + ctx.FinalDecision = new FinalDecision(FinalDecisionType.Reject, ctx.CoverageCheck.Reason); + ctx.ClaimCase.Status = ClaimStatus.Rejected; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.Reject; + ctx.ClaimCase.FinalDecisionReason = ctx.CoverageCheck.Reason; + return; + } + + if (ctx.IsHighRisk || ctx.Request.InjuryInvolved) + { + ctx.RequiresManualReview = true; + ctx.FinalDecision = new FinalDecision(FinalDecisionType.ManualReview, "High-risk or injury case requires manual review."); + ctx.ClaimCase.Status = ClaimStatus.ManualReview; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.ManualReview; + ctx.ClaimCase.FinalDecisionReason = "High-risk or injury case requires manual review."; + return; + } + + if (ctx.DocumentVerification is { IsValid: false }) + { + ctx.FinalDecision = new FinalDecision(FinalDecisionType.RequestDocuments, "Document verification failed."); + ctx.ClaimCase.Status = ClaimStatus.WaitingForDocuments; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.RequestDocuments; + ctx.ClaimCase.FinalDecisionReason = "Document verification failed."; + return; + } + + var amount = ctx.RepairQuote?.Amount ?? ctx.DamageEstimate?.Amount ?? 0m; + ctx.ClaimCase.ApprovedPayoutAmount = amount; + + var canAutoPay = ctx.PaymentPrecheck?.CanPayAutomatically == true + && amount > 0 + && amount <= ctx.Policy!.CoverageLimit + && ctx.Policy.SupportsAutoPayment; + + if (canAutoPay) + { + ctx.IsEligibleForAutoApproval = true; + ctx.FinalDecision = new FinalDecision(FinalDecisionType.Approve, "Automatically approved.", amount); + ctx.ClaimCase.Status = ClaimStatus.Approved; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.Approve; + ctx.ClaimCase.FinalDecisionReason = "Automatically approved."; + return; + } + + ctx.RequiresManualReview = true; + ctx.FinalDecision = new FinalDecision(FinalDecisionType.ManualReview, "Requires adjuster review."); + ctx.ClaimCase.Status = ClaimStatus.ManualReview; + ctx.ClaimCase.FinalDecisionType = FinalDecisionType.ManualReview; + ctx.ClaimCase.FinalDecisionReason = "Requires adjuster review."; + await Task.CompletedTask; + })) + .AddConditional( + "auto-pay-or-manual", + (ctx, _) => ValueTask.FromResult(ctx.IsEligibleForAutoApproval), + whenTrue: pipeline => pipeline.AddStep("execute-payment", async (ctx, ct) => + { + await paymentService.ExecutePaymentAsync(ctx.ClaimId, ctx.ClaimCase.ApprovedPayoutAmount, ct); + ctx.ClaimCase.Status = ClaimStatus.Paid; + ctx.AddAudit($"Payment executed: {ctx.ClaimCase.ApprovedPayoutAmount:0.00}"); + }), + whenFalse: pipeline => pipeline.AddStep("assign-adjuster", async (ctx, ct) => + { + await claimRepository.AssignToAdjusterAsync(ctx.ClaimId, ct); + ctx.AddAudit("Claim assigned to adjuster."); + }))) + .AddStep("persist-claim-state", async (ctx, ct) => + { + await claimRepository.SaveAsync(ctx, ct); + ctx.AddAudit("Claim state persisted."); + }) + .AddStep("send-final-notification", async (ctx, ct) => + { + if (ctx.FinalDecision is not null) + { + await notificationService.NotifyFinalDecisionAsync(ctx.ClaimId, ctx.FinalDecision, ct); + ctx.Notifications.Add("Final decision notification sent."); + } + }) + .Build(); + } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimProcessingService.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimProcessingService.cs new file mode 100644 index 0000000..4c01d8e --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Services/ClaimProcessingService.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Models; +using ClaimFlow.Web.Pipeline; +using TaskPipeline.Abstractions; + +namespace ClaimFlow.Web.Services; + +public sealed class ClaimProcessingService( + IClaimRepository claimRepository, + IClaimPipelineFactory pipelineFactory) +{ + public async Task<(ClaimCase Claim, ExecutionSummary Summary, PipelineExecutionResult PipelineResult)> ProcessAsync( + ClaimRequest request, + CancellationToken cancellationToken) + { + var claim = new ClaimCase + { + CustomerName = request.CustomerName, + CustomerEmail = request.CustomerEmail, + PolicyNumber = request.PolicyNumber, + ClaimType = request.ClaimType, + Description = request.Description, + HasPoliceReport = request.HasPoliceReport, + HasPhotos = request.HasPhotos, + RequiresTowTruck = request.RequiresTowTruck, + InjuryInvolved = request.InjuryInvolved, + IsVipCustomer = request.IsVipCustomer, + Status = ClaimStatus.Received + }; + + await claimRepository.AddAsync(claim, cancellationToken); + + var context = new ClaimProcessingContext + { + ClaimId = claim.Id, + ClaimCase = claim, + Request = request + }; + + var pipeline = pipelineFactory.Create(); + var result = await pipeline.ExecuteAsync(context, cancellationToken); + var summary = ExecutionSummaryMapper.Map(result); + + var snapshot = new ClaimExecutionSnapshot + { + ClaimCaseId = claim.Id, + PipelineName = "claim-processing", + PipelineStatus = result.Status.ToString(), + DurationMs = result.Duration.TotalMilliseconds, + SummaryJson = JsonSerializer.Serialize(summary, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true + }) + }; + + await claimRepository.AddExecutionSnapshotAsync(snapshot, cancellationToken); + var savedClaim = await claimRepository.GetAsync(claim.Id, cancellationToken) ?? claim; + + return (savedClaim, summary, result); + } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Services/Implementations.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Services/Implementations.cs new file mode 100644 index 0000000..6c46b4e --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Services/Implementations.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using ClaimFlow.Web.Data; +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Models; +using ClaimFlow.Web.Pipeline; +using Microsoft.EntityFrameworkCore; + +namespace ClaimFlow.Web.Services; + +public sealed class ClaimValidationService : IClaimValidationService +{ + public Task ValidateAsync(ClaimRequest request, CancellationToken cancellationToken) + { + var valid = !string.IsNullOrWhiteSpace(request.CustomerName) + && !string.IsNullOrWhiteSpace(request.CustomerEmail) + && !string.IsNullOrWhiteSpace(request.PolicyNumber) + && request.Description.Length >= 10; + + return Task.FromResult(valid); + } +} + +public sealed class PolicyService(AppDbContext dbContext) : IPolicyService +{ + public async Task GetPolicyAsync(string policyNumber, CancellationToken cancellationToken) + { + var record = await dbContext.Policies + .AsNoTracking() + .FirstOrDefaultAsync(x => x.PolicyNumber == policyNumber, cancellationToken); + + return record is null + ? null + : new PolicySnapshot( + record.PolicyNumber, + record.IsActive, + record.CoverageLimit, + record.CoversGlass, + record.CoversTowTruck, + record.SupportsAutoPayment); + } + + public Task CheckCoverageAsync(PolicySnapshot policy, ClaimRequest request, CancellationToken cancellationToken) + { + if (!policy.IsActive) + { + return Task.FromResult(new CoverageCheckResult(false, "Policy is inactive.")); + } + + if (request.RequiresTowTruck && !policy.CoversTowTruck) + { + return Task.FromResult(new CoverageCheckResult(false, "Tow truck service is not covered by the policy.")); + } + + if (request.Description.Contains("glass", StringComparison.OrdinalIgnoreCase) && !policy.CoversGlass) + { + return Task.FromResult(new CoverageCheckResult(false, "Glass damage is not covered by the policy.")); + } + + return Task.FromResult(new CoverageCheckResult(true, "Covered by policy.")); + } +} + +public sealed class FraudService : IFraudService +{ + public Task CheckAsync(ClaimRequest request, CancellationToken cancellationToken) + { + var score = 0.12; + + if (!request.HasPoliceReport) + { + score += 0.18; + } + + if (!request.HasPhotos) + { + score += 0.22; + } + + if (request.InjuryInvolved) + { + score += 0.20; + } + + if (request.Description.Contains("cash", StringComparison.OrdinalIgnoreCase)) + { + score += 0.16; + } + + if (request.IsVipCustomer) + { + score -= 0.04; + } + + score = Math.Clamp(score, 0, 0.99); + var requiresReview = score >= 0.55; + var summary = requiresReview ? "High-risk signal pattern." : "No major fraud indicators."; + + return Task.FromResult(new FraudCheckResult(score, requiresReview, summary)); + } +} + +public sealed class DocumentService : IDocumentService +{ + public Task CheckCompletenessAsync(ClaimRequest request, CancellationToken cancellationToken) + { + var missing = new List(); + + if (!request.HasPhotos) + { + missing.Add("Damage photos"); + } + + if (!request.HasPoliceReport && request.InjuryInvolved) + { + missing.Add("Police report"); + } + + return Task.FromResult(new DocumentCompletenessResult(missing.Count == 0, missing)); + } + + public Task VerifyAsync(ClaimRequest request, CancellationToken cancellationToken) + { + var missing = new List(); + + if (!request.HasPhotos) + { + missing.Add("Damage photos"); + } + + var isValid = missing.Count == 0; + return Task.FromResult(new DocumentVerificationResult(isValid, missing)); + } +} + +public sealed class DamageEstimator : IDamageEstimator +{ + public Task EstimateAsync(ClaimRequest request, CancellationToken cancellationToken) + { + decimal amount = request.ClaimType switch + { + ClaimType.Kasko => 1800m, + ClaimType.Osago => 950m, + _ => 750m + }; + + if (request.Description.Contains("bumper", StringComparison.OrdinalIgnoreCase)) + { + amount += 450m; + } + + if (request.Description.Contains("door", StringComparison.OrdinalIgnoreCase)) + { + amount += 700m; + } + + if (request.Description.Contains("engine", StringComparison.OrdinalIgnoreCase)) + { + amount += 2900m; + } + + if (request.InjuryInvolved) + { + amount += 600m; + } + + return Task.FromResult(new DamageEstimateResult(amount, $"Estimated by rules engine: {amount:C}.")); + } +} + +public sealed class RepairQuoteService : IRepairQuoteService +{ + public Task GetBestQuoteAsync(ClaimRequest request, CancellationToken cancellationToken) + { + var partner = request.IsVipCustomer ? "Prime Auto Partner" : "City Repair Hub"; + var amount = request.ClaimType == ClaimType.Kasko ? 2100m : 1100m; + return Task.FromResult(new RepairQuoteResult(amount, partner)); + } +} + +public sealed class PaymentService : IPaymentService +{ + public Task PrecheckAsync(ClaimRequest request, CancellationToken cancellationToken) + { + var canAutoPay = !request.InjuryInvolved; + var reason = canAutoPay ? "Automatic payment is allowed." : "Human review required due to injury involvement."; + return Task.FromResult(new PaymentPrecheckResult(canAutoPay, reason)); + } + + public Task ExecutePaymentAsync(Guid claimId, decimal amount, CancellationToken cancellationToken) + => Task.CompletedTask; +} + +public sealed class NotificationService : INotificationService +{ + public Task RequestMissingDocumentsAsync(Guid claimId, IReadOnlyList missingDocuments, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task NotifyClaimInProgressAsync(Guid claimId, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task NotifyFinalDecisionAsync(Guid claimId, FinalDecision finalDecision, CancellationToken cancellationToken) + => Task.CompletedTask; +} + +public sealed class ClaimRepository(AppDbContext dbContext) : IClaimRepository +{ + public Task GetAsync(Guid id, CancellationToken cancellationToken) + => dbContext.Claims + .Include(x => x.Executions.OrderByDescending(e => e.CreatedAtUtc)) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + public Task> ListAsync(CancellationToken cancellationToken) + => dbContext.Claims + .Include(x => x.Executions) + .OrderByDescending(x => x.CreatedAtUtc) + .ToListAsync(cancellationToken); + + public async Task AddAsync(ClaimCase claim, CancellationToken cancellationToken) + { + dbContext.Claims.Add(claim); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task SaveAsync(ClaimProcessingContext context, CancellationToken cancellationToken) + { + var claim = await dbContext.Claims.FirstAsync(x => x.Id == context.ClaimId, cancellationToken); + + claim.Status = context.ClaimCase.Status; + claim.EstimatedDamageAmount = context.ClaimCase.EstimatedDamageAmount; + claim.ApprovedPayoutAmount = context.ClaimCase.ApprovedPayoutAmount; + claim.FinalDecisionType = context.ClaimCase.FinalDecisionType; + claim.FinalDecisionReason = context.ClaimCase.FinalDecisionReason; + claim.MissingDocumentsJson = JsonSerializer.Serialize(context.MissingDocuments); + claim.WarningsJson = JsonSerializer.Serialize(context.Warnings); + claim.ProcessedAtUtc = DateTime.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AssignToAdjusterAsync(Guid claimId, CancellationToken cancellationToken) + { + var claim = await dbContext.Claims.FirstAsync(x => x.Id == claimId, cancellationToken); + claim.Status = ClaimStatus.ManualReview; + claim.FinalDecisionReason = "Assigned to adjuster for manual assessment."; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AddExecutionSnapshotAsync(ClaimExecutionSnapshot snapshot, CancellationToken cancellationToken) + { + dbContext.Executions.Add(snapshot); + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Services/Interfaces.cs b/examples/ClaimFlow/src/ClaimFlow.Web/Services/Interfaces.cs new file mode 100644 index 0000000..175d523 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Services/Interfaces.cs @@ -0,0 +1,66 @@ +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Models; +using ClaimFlow.Web.Pipeline; +using TaskPipeline.Abstractions; + +namespace ClaimFlow.Web.Services; + +public interface IClaimValidationService +{ + Task ValidateAsync(ClaimRequest request, CancellationToken cancellationToken); +} + +public interface IPolicyService +{ + Task GetPolicyAsync(string policyNumber, CancellationToken cancellationToken); + Task CheckCoverageAsync(PolicySnapshot policy, ClaimRequest request, CancellationToken cancellationToken); +} + +public interface IFraudService +{ + Task CheckAsync(ClaimRequest request, CancellationToken cancellationToken); +} + +public interface IDocumentService +{ + Task CheckCompletenessAsync(ClaimRequest request, CancellationToken cancellationToken); + Task VerifyAsync(ClaimRequest request, CancellationToken cancellationToken); +} + +public interface IDamageEstimator +{ + Task EstimateAsync(ClaimRequest request, CancellationToken cancellationToken); +} + +public interface IRepairQuoteService +{ + Task GetBestQuoteAsync(ClaimRequest request, CancellationToken cancellationToken); +} + +public interface IPaymentService +{ + Task PrecheckAsync(ClaimRequest request, CancellationToken cancellationToken); + Task ExecutePaymentAsync(Guid claimId, decimal amount, CancellationToken cancellationToken); +} + +public interface INotificationService +{ + Task RequestMissingDocumentsAsync(Guid claimId, IReadOnlyList missingDocuments, CancellationToken cancellationToken); + Task NotifyClaimInProgressAsync(Guid claimId, CancellationToken cancellationToken); + Task NotifyFinalDecisionAsync(Guid claimId, FinalDecision finalDecision, CancellationToken cancellationToken); +} + +public interface IClaimRepository +{ + Task GetAsync(Guid id, CancellationToken cancellationToken); + Task> ListAsync(CancellationToken cancellationToken); + Task AddAsync(ClaimCase claim, CancellationToken cancellationToken); + Task SaveAsync(ClaimProcessingContext context, CancellationToken cancellationToken); + Task AssignToAdjusterAsync(Guid claimId, CancellationToken cancellationToken); + Task AddExecutionSnapshotAsync(ClaimExecutionSnapshot snapshot, CancellationToken cancellationToken); +} + +public interface IClaimPipelineFactory +{ + IPipeline Create(); +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/ViewModels/HomeViewModels.cs b/examples/ClaimFlow/src/ClaimFlow.Web/ViewModels/HomeViewModels.cs new file mode 100644 index 0000000..f04a8a1 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/ViewModels/HomeViewModels.cs @@ -0,0 +1,38 @@ +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Pipeline; + +namespace ClaimFlow.Web.ViewModels; + +public sealed class DashboardViewModel +{ + public List Claims { get; set; } = []; + public List Policies { get; set; } = []; +} + +public sealed class ClaimListItemViewModel +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string PolicyNumber { get; set; } = string.Empty; + public ClaimType ClaimType { get; set; } + public ClaimStatus Status { get; set; } + public FinalDecisionType FinalDecisionType { get; set; } + public decimal ApprovedPayoutAmount { get; set; } + public DateTime CreatedAtUtc { get; set; } +} + +public sealed class DemoPolicyViewModel +{ + public string PolicyNumber { get; set; } = string.Empty; + public bool IsActive { get; set; } + public decimal CoverageLimit { get; set; } + public bool SupportsAutoPayment { get; set; } +} + +public sealed class ClaimDetailsViewModel +{ + public ClaimCase Claim { get; set; } = default!; + public ExecutionSummary? ExecutionSummary { get; set; } + public List MissingDocuments { get; set; } = []; + public List Warnings { get; set; } = []; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Details.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Details.cshtml new file mode 100644 index 0000000..ce382bc --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Details.cshtml @@ -0,0 +1,107 @@ +@model ClaimDetailsViewModel +@{ + ViewData["Title"] = "Claim Details"; +} + +
+
+
Claim details
+

@Model.Claim.CustomerName

+
@Model.Claim.Id
+
+
+ @Model.Claim.ClaimType + @Model.Claim.Status +
+
+ +
+
+
+
+
Overview
+

Decision summary

+
+
Customer
@Model.Claim.CustomerName
+
Email
@Model.Claim.CustomerEmail
+
Policy
@Model.Claim.PolicyNumber
+
Decision
@Model.Claim.FinalDecisionType
+
Reason
@Model.Claim.FinalDecisionReason
+
Estimated
@Model.Claim.EstimatedDamageAmount.ToString("C")
+
Approved
@Model.Claim.ApprovedPayoutAmount.ToString("C")
+
+
+
+
+
+
+
+
Request
+

Incident description

+

@Model.Claim.Description

+ +
+ Photos: @(Model.Claim.HasPhotos ? "Yes" : "No") + Police report: @(Model.Claim.HasPoliceReport ? "Yes" : "No") + Tow truck: @(Model.Claim.RequiresTowTruck ? "Yes" : "No") + Injury: @(Model.Claim.InjuryInvolved ? "Yes" : "No") + VIP: @(Model.Claim.IsVipCustomer ? "Yes" : "No") +
+ + @if (Model.MissingDocuments.Count > 0) + { +
+
Missing documents
+
    + @foreach (var item in Model.MissingDocuments) + { +
  • @item
  • + } +
+
+ } +
+
+
+
+ +@if (Model.ExecutionSummary is not null) +{ +
+
+
+
+
Observability
+

Execution tree

+
+
+ @Model.ExecutionSummary.Status + @Model.ExecutionSummary.DurationMs.ToString("0") ms +
+
+ + @if (Model.ExecutionSummary.Root is not null) + { + + } +
+
+} + + + +@functions { + private static string StatusClass(ClaimStatus status) => status switch + { + ClaimStatus.Paid => "status-success", + ClaimStatus.Approved => "status-success", + ClaimStatus.ManualReview => "status-warning", + ClaimStatus.WaitingForDocuments => "status-warning", + ClaimStatus.Rejected => "status-danger", + ClaimStatus.Failed => "status-danger", + ClaimStatus.Cancelled => "status-neutral", + _ => "status-neutral" + }; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Error.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Error.cshtml new file mode 100644 index 0000000..66147e2 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Error.cshtml @@ -0,0 +1,11 @@ +@{ + ViewData["Title"] = "Error"; +} + +
+
+

Something went wrong

+

The application hit an unexpected error.

+ Back to dashboard +
+
diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Index.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Index.cshtml new file mode 100644 index 0000000..e47a7c5 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/Index.cshtml @@ -0,0 +1,140 @@ +@model DashboardViewModel +@{ + ViewData["Title"] = "Dashboard"; +} + +
+
+
+
+
+
TaskPipeline showcase
+
+
+

Insurance claim automation with a clear execution trail

+

+ A compact ASP.NET Core demo that highlights sequential steps, conditional routing, + parallel branches, merge logic, fault tolerance, and execution snapshots stored in SQLite. +

+
+ Create claim +
+ +
+
+
Processed claims
+
@Model.Claims.Count
+
+
+
Demo policies
+
@Model.Policies.Count
+
+
+
Storage
+
SQLite
+
+
+
+
+
+
+
+
+
+
+
Reference data
+

Demo policies

+
+ @Model.Policies.Count items +
+
+ @foreach (var policy in Model.Policies) + { +
+
+
@policy.PolicyNumber
+ @(policy.IsActive ? "Active" : "Inactive") +
+
Coverage @policy.CoverageLimit.ToString("C")
+
Auto-pay @(policy.SupportsAutoPayment ? "enabled" : "disabled")
+
+ } +
+
+
+
+
+
+ +
+
+
+
+
Operations
+

Recent claims

+
+ @Model.Claims.Count total +
+ + @if (Model.Claims.Count == 0) + { +
+
+
No claims yet
+

Create the first claim to see the pipeline, branching, and stored execution tree in action.

+ Create claim +
+ } + else + { +
+ + + + + + + + + + + + + + @foreach (var item in Model.Claims) + { + + + + + + + + + + } + +
CustomerPolicyTypeStatusDecisionPayout
+
@item.CustomerName
+
@item.CreatedAtUtc.ToLocalTime()
+
@item.PolicyNumber@item.ClaimType@item.Status@item.FinalDecisionType@item.ApprovedPayoutAmount.ToString("C") + Open +
+
+ } +
+
+ +@functions { + private static string StatusClass(ClaimStatus status) => status switch + { + ClaimStatus.Paid => "status-success", + ClaimStatus.Approved => "status-success", + ClaimStatus.ManualReview => "status-warning", + ClaimStatus.WaitingForDocuments => "status-warning", + ClaimStatus.Rejected => "status-danger", + ClaimStatus.Failed => "status-danger", + ClaimStatus.Cancelled => "status-neutral", + _ => "status-neutral" + }; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/NewClaim.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/NewClaim.cshtml new file mode 100644 index 0000000..6ab83d4 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Home/NewClaim.cshtml @@ -0,0 +1,103 @@ +@model ClaimRequest +@{ + ViewData["Title"] = "New Claim"; +} + +
+
+
+
+
+
+
Create claim
+

Submit and process a new claim

+

The form runs the pipeline immediately and stores both business data and execution details.

+
+ Back +
+ +
+ @Html.AntiForgeryToken() +
+ +
+
+
+
Customer and case data
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
+
+
+
Checklist
+
+ + + + +
+
+ Tip: use one of the demo policies from the dashboard to trigger different pipeline branches. +
+
+
+
+ +
+ + Cancel +
+
+
+
+
+
+ +@section Scripts { + +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ExecutionNode.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ExecutionNode.cshtml new file mode 100644 index 0000000..962a1ff --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ExecutionNode.cshtml @@ -0,0 +1,58 @@ +@model ClaimFlow.Web.Pipeline.ExecutionNodeSummary + +
+
+
+
+
@Model.Name
+
@Model.Type
+
+
+
@Model.Status
+
@Model.DurationMs.ToString("0") ms
+
+
+ + @if (!string.IsNullOrWhiteSpace(Model.Exception)) + { +
@Model.Exception
+ } + + @if (Model.Metadata.Count > 0) + { + + } +
+ + @if (Model.Children.Count > 0) + { +
+ @foreach (var child in Model.Children) + { + + } +
+ } +
+ +@functions { + private static string NodeStatusClass(string? status) => status?.ToLowerInvariant() switch + { + "success" => "status-success", + "failed" => "status-danger", + "cancelled" => "status-neutral", + "skipped" => "status-neutral", + _ => "status-warning" + }; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_Layout.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..989eec7 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_Layout.cshtml @@ -0,0 +1,45 @@ + + + + + + @ViewData["Title"] - ClaimFlow + + + + + + + +
+ + +
+ @if (TempData["Flash"] is string flash) + { +
@flash
+ } + @RenderBody() +
+
+ + +@await RenderSectionAsync("Scripts", required: false) + + diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ValidationScriptsPartial.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ValidationScriptsPartial.cshtml new file mode 100644 index 0000000..6875992 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/Shared/_ValidationScriptsPartial.cshtml @@ -0,0 +1,3 @@ + + + diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewImports.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewImports.cshtml new file mode 100644 index 0000000..9d9cde2 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using ClaimFlow.Web +@using ClaimFlow.Web.Domain +@using ClaimFlow.Web.Models +@using ClaimFlow.Web.ViewModels +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewStart.cshtml b/examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/appsettings.json b/examples/ClaimFlow/src/ClaimFlow.Web/appsettings.json new file mode 100644 index 0000000..e045c0e --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=claimflow.db" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db b/examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db new file mode 100644 index 0000000000000000000000000000000000000000..9a472209435d88229f65ab7a0ca30d67df87a462 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WY9}{#S79dK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3ArNJIx= literal 0 HcmV?d00001 diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db-shm b/examples/ClaimFlow/src/ClaimFlow.Web/claimflow.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..8906bf1c87e5f5f546058dd8270b86522bc599eb GIT binary patch literal 32768 zcmeI)J8r^26a~;R5I#u?N-A0+Mf5BHQf3cXL7FTf3!r6Dlynq`K3jmi7qCQ|63w~N zm94Qg_M8R0$#GsX&86e#xDKQHm)GUncDMN{&0_s^djFoS4xiKE$K;>y$5wyIea|}8G2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+z}*Yv)CnO_ zr$DES%2S<3ck1c|x-okwrxFQ)`UG;umJp~@Ag9X-fjR|puA&gAQy?db3V}KWa+a6)}_mpur=G*x(cvsRs}Jhn|GwB^CsvbAZKq@&Xqlbd?OT@q)u zGgjVA9byb=8hZue9nui+0>Ola_!AHy5WD~(K;!8J(bs75lo_=hD z%e!+Y|E$xLr;Ex`%B8vXg>B_y@li?eEiItTx;q65zVwWb2=dZCDJx{g^Fn5FbW}); zXL9*$jN<9g4<4^yZz7u>i)D`p2c;uIY%)Kd&d@f-q)dMILWcGGDCDIn%5ouFJH>=r zQHp9=$;c(e*E&hgl+CKqBvG=e%M5yq5>Szj-m{@xwN#SzxdVn)ZjwbpS7cL}iJ6mT zVdW(C6sdZOT3Iy|vQJ2jXQlMMjHR=zxM&wsQdY_&q?}MINZRdQ=HJJvSW+65s8S|k zxkM}}b$9knw6%Nt`}reQ;Torl^k409Lpfbl$^~WF+YJrHyli1_nV5IentkVIoi1-r z5C6* zDx+#iHCHY{pOwbgSCkDBZ<&#i*PhJl)xxRf>2lRdMbk|qRyDN=d9I|CX|u~SrQ*A= zmv?!$Z|5%^wXm&LVboq7jnK8o#unMoZk9JkdxbjTMUJXrY9&Q)(XYeIIGFj?5y|p={Hbo4Nm%Z!~Sf~KIbo9 zFSvC1^#l3CfdkzvvUVwf%^^Sl0uX=z1Rwwb2tWV=5P$##Ah13K*foO1>jEcd2VRZ7 zQM}U0B5_@SDIh=q0uX=z1Rwwb2tWV=5P$##*0X^19RU0L1;1oBy)tq&_%`b=;Pkx9 zd2V_>p$P&6AOHafKmY;|fB*y_009U<00Q@qfY<5fdfYoYI@||y2gRu0?+?(^Kj44R zK?^#zyWJh*xqV`g;)4+@p10yVIy%@2lsGVCrKdw;;{z=A7dY2-ee;#6o!8iP0p4?) zqXz;6AOHafKmY;|fB*y_009U<00Qeqpo@3*@b^lVQ?9lA~0{h?m;e+XSw4X7) zz`7}L90&v;009U<00Izz00bZa0SG{#m4J&aw66<1|J+wME?*n_9pekMBEvEWKmY;| zfB*y_009U<00Izzz`7A&zf0h@uM1q?|Hr2HZs-!bF5vWh$$9R0exwNk1Rwwb2tWV= z5P$##AOHafKmY=3N}!9rE?|!_;9zg;kqDd{ojr@A4A3Dp{7&J;&fw<0v8$if#uqrn zc}}gVgT~H600Izz00bZa0SG_<0uX=z1nyUX`K|l|lMnarqQL_Dw{4>l0Nw5_Te=fm zC>V?l2L=blKs*{2!^wzWjK=+8F%b&K!^6qpcq%R(FtoA|*YxtNOmovTv$v=f2dVf(PKe>a8UGz#6U<0ghoPxBcbrXa4;H< z4h|6(8)IOxzrdd}ruXFwFMPxL3-F#VIC>yJ00Izz00bZa0SG_<0uX=z1R$`+1a>(m zJ63bSfex#ozd-EnuU*$3|IW$!3ph4?PW=Vw1pxvOfB*y_009U<00Izz00bbgrUd4< zI_TDXAG7upP(6yrnNis`Fx)4w}(OzkzR6INfD-tvi)p!Yd#zvp?mg2;o)c`5?mAg1(N-} zU%&L(M=91{z~Om|`U}tt0t6rc0SG_<0uX=z1Rwwb2tWV=_k%#MbCO@p#SO=5`wRRH D9kFpX literal 0 HcmV?d00001 diff --git a/examples/ClaimFlow/src/ClaimFlow.Web/wwwroot/css/site.css b/examples/ClaimFlow/src/ClaimFlow.Web/wwwroot/css/site.css new file mode 100644 index 0000000..d5088ed --- /dev/null +++ b/examples/ClaimFlow/src/ClaimFlow.Web/wwwroot/css/site.css @@ -0,0 +1,468 @@ +:root { + --bg: #f4f7f6; + --surface: rgba(255,255,255,0.82); + --surface-strong: #ffffff; + --border: rgba(15, 23, 42, 0.08); + --border-strong: rgba(15, 23, 42, 0.12); + --text: #0f172a; + --muted: #64748b; + --primary: #147d64; + --primary-strong: #0f6a55; + --primary-soft: rgba(20, 125, 100, 0.1); + --success-soft: #dcfce7; + --warning-soft: #fef3c7; + --danger-soft: #fee2e2; + --neutral-soft: #e5e7eb; + --shadow: 0 12px 40px rgba(15, 23, 42, 0.07); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; +} + +html { + font-size: 15px; +} + +body { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(20,125,100,0.08), transparent 26%), + radial-gradient(circle at top right, rgba(15,23,42,0.06), transparent 20%), + var(--bg); + min-height: 100vh; +} + +.page-shell { + min-height: 100vh; +} + +.app-container { + max-width: 1280px; +} + +.app-navbar { + backdrop-filter: blur(18px); + background: rgba(244, 247, 246, 0.72); + border-bottom: 1px solid rgba(255,255,255,0.35); +} + +.brand-mark { + display: inline-flex; + align-items: center; + gap: 0.8rem; + font-weight: 800; + letter-spacing: -0.03em; + color: var(--text); + text-decoration: none; +} + +.brand-dot { + width: 12px; + height: 12px; + border-radius: 999px; + background: linear-gradient(135deg, var(--primary), #7dd3ae); + box-shadow: 0 0 0 6px rgba(20,125,100,0.12); +} + +.nav-pills-soft .nav-link { + color: var(--muted); + font-weight: 600; + border-radius: 999px; + padding: 0.7rem 1rem; +} + +.nav-pills-soft .nav-link:hover, +.nav-pills-soft .nav-link:focus, +.nav-pills-soft .nav-link.active { + color: var(--text); + background: rgba(255,255,255,0.7); +} + +.nav-link-accent { + background: rgba(20,125,100,0.1); + color: var(--primary) !important; +} + +.panel, +.subpanel, +.execution-card { + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow); + border-radius: var(--radius-xl); + backdrop-filter: blur(16px); +} + +.panel-hero { + background: + linear-gradient(135deg, rgba(20,125,100,0.12), rgba(255,255,255,0.86) 55%), + var(--surface); +} + +.panel-body { + position: relative; +} + +.eyebrow, +.section-kicker { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.77rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--primary); + font-weight: 700; +} + +.display-title { + font-size: clamp(2rem, 4vw, 3.25rem); + line-height: 1.02; + font-weight: 800; + letter-spacing: -0.05em; + max-width: 13ch; +} + +.lead-copy, +.text-muted, +.text-secondary, +.text-body-secondary, +.item-meta, +.table-subtitle, +.execution-type { + color: var(--muted) !important; +} + +.section-title { + font-weight: 700; + letter-spacing: -0.03em; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.75rem; +} + +.metric-card, +.policy-item, +.toggle-card, +.choice-card, +.metadata-item { + background: rgba(255,255,255,0.78); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.metric-card { + padding: 1rem 1.1rem; +} + +.metric-label { + color: var(--muted); + font-size: 0.82rem; + margin-bottom: 0.4rem; +} + +.metric-value { + font-size: 1.35rem; + font-weight: 750; + letter-spacing: -0.04em; +} + +.soft-badge, +.status-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.4rem 0.75rem; + font-size: 0.78rem; + font-weight: 700; + border: 1px solid transparent; +} + +.soft-badge { + background: rgba(255,255,255,0.8); + border-color: var(--border); + color: var(--text); +} + +.soft-badge-success, +.status-success { + background: var(--success-soft); + color: #166534; +} + +.soft-badge-danger, +.status-danger { + background: var(--danger-soft); + color: #991b1b; +} + +.status-warning { + background: var(--warning-soft); + color: #92400e; +} + +.status-neutral { + background: var(--neutral-soft); + color: #475569; +} + +.policy-item { + padding: 1rem; +} + +.empty-state { + padding: 3rem 1rem; + text-align: center; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: rgba(255,255,255,0.52); +} + +.empty-state-icon { + font-size: 2rem; + color: var(--primary); + margin-bottom: 0.75rem; +} + +.app-table-wrap { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + background: rgba(255,255,255,0.72); +} + +.app-table { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(248, 250, 252, 0.9); + --bs-table-hover-bg: rgba(20,125,100,0.04); +} + +.app-table thead th { + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + background: rgba(248, 250, 252, 0.86); + border-bottom-width: 1px; + padding-top: 1rem; + padding-bottom: 1rem; +} + +.app-table td { + padding-top: 1rem; + padding-bottom: 1rem; + border-color: rgba(15, 23, 42, 0.06); +} + +.app-btn-primary, +.app-btn-secondary { + border-radius: 999px; + padding: 0.78rem 1.1rem; + font-weight: 700; + border: 1px solid transparent; +} + +.app-btn-primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.app-btn-primary:hover, +.app-btn-primary:focus { + background: var(--primary-strong); + border-color: var(--primary-strong); + color: #fff; +} + +.app-btn-secondary { + background: rgba(255,255,255,0.78); + border-color: var(--border); + color: var(--text); +} + +.app-btn-secondary:hover, +.app-btn-secondary:focus { + background: #fff; + border-color: var(--border-strong); + color: var(--text); +} + +.claim-form-grid .form-label { + font-weight: 600; + color: var(--text); +} + +.app-control { + border-radius: 14px; + border-color: rgba(148, 163, 184, 0.35); + padding: 0.85rem 1rem; + box-shadow: none !important; + background: rgba(255,255,255,0.94); +} + +.app-control:focus { + border-color: rgba(20,125,100,0.45); + box-shadow: 0 0 0 4px rgba(20,125,100,0.12) !important; +} + +.subpanel { + padding: 1.4rem; + border-radius: var(--radius-lg); + box-shadow: none; + background: rgba(255,255,255,0.55); +} + +.subpanel-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--text); +} + +.toggle-card { + padding: 0.9rem 1rem; +} + +.selection-stack { + display: grid; + gap: 0.8rem; +} + +.choice-card { + display: flex; + gap: 0.85rem; + align-items: center; + padding: 0.95rem 1rem; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.choice-card:hover { + transform: translateY(-1px); + border-color: rgba(20,125,100,0.3); + background: rgba(255,255,255,0.92); +} + +.choice-card .form-check-input { + margin-top: 0; +} + +.helper-note, +.app-alert, +.app-warning-box, +.app-error-box { + border-radius: var(--radius-md); + padding: 1rem 1.1rem; +} + +.helper-note, +.app-alert { + background: rgba(20,125,100,0.08); + border: 1px solid rgba(20,125,100,0.12); + color: var(--primary-strong); +} + +.app-warning-box { + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.18); +} + +.app-error-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.18); + color: #991b1b; +} + +.detail-list { + display: grid; + gap: 0.9rem; +} + +.detail-list div { + display: grid; + grid-template-columns: 110px 1fr; + gap: 1rem; +} + +.detail-list dt { + color: var(--muted); + font-weight: 600; +} + +.detail-list dd { + margin: 0; + font-weight: 600; +} + +.tag-grid, +.execution-summary-meta, +.metadata-grid { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.execution-node + .execution-node { + margin-top: 1rem; +} + +.execution-card { + padding: 1.1rem 1.2rem; + border-radius: 20px; +} + +.execution-children { + margin-left: 1.4rem; + padding-left: 1.1rem; + border-left: 1px dashed rgba(148, 163, 184, 0.5); + margin-top: 1rem; +} + +.metadata-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.metadata-item { + padding: 0.8rem 0.9rem; +} + +.metadata-item span { + display: block; + color: var(--muted); + font-size: 0.78rem; + margin-bottom: 0.2rem; +} + +@media (max-width: 991.98px) { + .metric-grid { + grid-template-columns: 1fr; + } + + .display-title { + max-width: unset; + } +} + +@media (max-width: 767.98px) { + .detail-list div { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .panel, + .execution-card, + .subpanel { + border-radius: 20px; + } + + .execution-children { + margin-left: 0.75rem; + padding-left: 0.75rem; + } +} diff --git a/examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimFlow.Web.Tests.csproj b/examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimFlow.Web.Tests.csproj new file mode 100644 index 0000000..f3932df --- /dev/null +++ b/examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimFlow.Web.Tests.csproj @@ -0,0 +1,19 @@ + + + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimProcessingServiceTests.cs b/examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimProcessingServiceTests.cs new file mode 100644 index 0000000..f5532d5 --- /dev/null +++ b/examples/ClaimFlow/tests/ClaimFlow.Web.Tests/ClaimProcessingServiceTests.cs @@ -0,0 +1,118 @@ +using ClaimFlow.Web.Data; +using ClaimFlow.Web.Domain; +using ClaimFlow.Web.Models; +using ClaimFlow.Web.Services; +using Microsoft.EntityFrameworkCore; + +namespace ClaimFlow.Web.Tests; + +public sealed class ClaimProcessingServiceTests +{ + [Fact] + public async Task HappyPath_ApprovesAndPaysClaim() + { + await using var dbContext = CreateDbContext(nameof(HappyPath_ApprovesAndPaysClaim)); + var service = CreateService(dbContext); + + var request = new ClaimRequest + { + CustomerName = "Dmitry", + CustomerEmail = "dmitry@example.com", + PolicyNumber = "KSK-1001", + ClaimType = ClaimType.Kasko, + Description = "Front bumper and door damage with photos.", + HasPhotos = true, + HasPoliceReport = true, + RequiresTowTruck = false, + InjuryInvolved = false, + IsVipCustomer = true + }; + + var result = await service.ProcessAsync(request, CancellationToken.None); + + Assert.Equal(ClaimStatus.Paid, result.Claim.Status); + Assert.Equal(FinalDecisionType.Approve, result.Claim.FinalDecisionType); + Assert.NotNull(result.ExecutionSummary.Root); + } + + [Fact] + public async Task MissingDocuments_SendsClaimToWaitingForDocuments() + { + await using var dbContext = CreateDbContext(nameof(MissingDocuments_SendsClaimToWaitingForDocuments)); + var service = CreateService(dbContext); + + var request = new ClaimRequest + { + CustomerName = "Alex", + CustomerEmail = "alex@example.com", + PolicyNumber = "KSK-1001", + ClaimType = ClaimType.Kasko, + Description = "Need payout for bumper damage.", + HasPhotos = false, + HasPoliceReport = true, + RequiresTowTruck = false, + InjuryInvolved = false, + IsVipCustomer = false + }; + + var result = await service.ProcessAsync(request, CancellationToken.None); + + Assert.Equal(ClaimStatus.WaitingForDocuments, result.Claim.Status); + Assert.Equal(FinalDecisionType.RequestDocuments, result.Claim.FinalDecisionType); + } + + [Fact] + public async Task InactivePolicy_RejectsClaim() + { + await using var dbContext = CreateDbContext(nameof(InactivePolicy_RejectsClaim)); + var service = CreateService(dbContext); + + var request = new ClaimRequest + { + CustomerName = "Ivan", + CustomerEmail = "ivan@example.com", + PolicyNumber = "KSK-9000", + ClaimType = ClaimType.Kasko, + Description = "Glass damage with photos.", + HasPhotos = true, + HasPoliceReport = true, + RequiresTowTruck = false, + InjuryInvolved = false, + IsVipCustomer = false + }; + + var result = await service.ProcessAsync(request, CancellationToken.None); + + Assert.Equal(ClaimStatus.Rejected, result.Claim.Status); + Assert.Equal(FinalDecisionType.Reject, result.Claim.FinalDecisionType); + } + + private static ClaimProcessingService CreateService(AppDbContext dbContext) + { + var repository = new ClaimRepository(dbContext); + var factory = new ClaimPipelineFactory( + new ClaimValidationService(), + new PolicyService(dbContext), + new FraudService(), + new DocumentService(), + new DamageEstimator(), + new RepairQuoteService(), + new PaymentService(), + new NotificationService(), + repository); + + return new ClaimProcessingService(repository, factory); + } + + private static AppDbContext CreateDbContext(string name) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(name) + .Options; + + var dbContext = new AppDbContext(options); + dbContext.Database.EnsureDeleted(); + dbContext.Database.EnsureCreated(); + return dbContext; + } +} diff --git a/src/TaskPipeline/Abstractions/BranchExecutionMode.cs b/src/TaskPipeline/Abstractions/BranchExecutionMode.cs new file mode 100644 index 0000000..ed2c9d5 --- /dev/null +++ b/src/TaskPipeline/Abstractions/BranchExecutionMode.cs @@ -0,0 +1,10 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Defines whether branches are executed one-by-one or concurrently. +/// +public enum BranchExecutionMode +{ + Sequential, + Parallel +} diff --git a/src/TaskPipeline/Abstractions/ExecutionStatus.cs b/src/TaskPipeline/Abstractions/ExecutionStatus.cs new file mode 100644 index 0000000..c9f7c83 --- /dev/null +++ b/src/TaskPipeline/Abstractions/ExecutionStatus.cs @@ -0,0 +1,12 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Represents the final state of a pipeline node execution. +/// +public enum ExecutionStatus +{ + Success, + Skipped, + Failed, + Cancelled +} diff --git a/src/TaskPipeline/Abstractions/IBranchMergeStrategy.cs b/src/TaskPipeline/Abstractions/IBranchMergeStrategy.cs new file mode 100644 index 0000000..cd3e9db --- /dev/null +++ b/src/TaskPipeline/Abstractions/IBranchMergeStrategy.cs @@ -0,0 +1,18 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Provides a hook that runs after branch execution and before the pipeline continues. +/// +/// Pipeline context type. +public interface IBranchMergeStrategy +{ + /// + /// Gets the merge strategy name used in diagnostics. + /// + string Name { get; } + + /// + /// Merges branch outcomes back into the shared context. + /// + ValueTask MergeAsync(TContext context, IReadOnlyList branchResults, CancellationToken cancellationToken); +} diff --git a/src/TaskPipeline/Abstractions/IPipeline.cs b/src/TaskPipeline/Abstractions/IPipeline.cs new file mode 100644 index 0000000..60d69bf --- /dev/null +++ b/src/TaskPipeline/Abstractions/IPipeline.cs @@ -0,0 +1,20 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Represents a strongly typed executable pipeline. +/// +/// Pipeline context type. +public interface IPipeline +{ + /// + /// Gets the friendly pipeline name. + /// + string Name { get; } + + /// + /// Executes the pipeline for the provided context. + /// + /// Typed execution context. + /// Cancellation token propagated through the entire graph. + ValueTask ExecuteAsync(TContext context, CancellationToken cancellationToken = default); +} diff --git a/src/TaskPipeline/Abstractions/IPipelineBehavior.cs b/src/TaskPipeline/Abstractions/IPipelineBehavior.cs new file mode 100644 index 0000000..e0d2d96 --- /dev/null +++ b/src/TaskPipeline/Abstractions/IPipelineBehavior.cs @@ -0,0 +1,17 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Optional middleware-like hook around node execution. +/// +/// Pipeline context type. +public interface IPipelineBehavior +{ + /// + /// Invokes the next behavior or the node itself. + /// + ValueTask InvokeAsync( + TContext context, + PipelineNodeExecutionContext nodeContext, + Func> next, + CancellationToken cancellationToken); +} diff --git a/src/TaskPipeline/Abstractions/IPipelineCondition.cs b/src/TaskPipeline/Abstractions/IPipelineCondition.cs new file mode 100644 index 0000000..88aacf7 --- /dev/null +++ b/src/TaskPipeline/Abstractions/IPipelineCondition.cs @@ -0,0 +1,18 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Represents a strongly typed asynchronous condition. +/// +/// Pipeline context type. +public interface IPipelineCondition +{ + /// + /// Gets the condition name used in diagnostics. + /// + string Name { get; } + + /// + /// Evaluates whether the associated node should execute. + /// + ValueTask CanExecuteAsync(TContext context, CancellationToken cancellationToken); +} diff --git a/src/TaskPipeline/Abstractions/IPipelineNode.cs b/src/TaskPipeline/Abstractions/IPipelineNode.cs new file mode 100644 index 0000000..2d893cd --- /dev/null +++ b/src/TaskPipeline/Abstractions/IPipelineNode.cs @@ -0,0 +1,23 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Represents an executable node in the pipeline graph. +/// +/// Pipeline context type. +public interface IPipelineNode +{ + /// + /// Gets the node name used in execution reports. + /// + string Name { get; } + + /// + /// Gets the semantic node kind. + /// + NodeKind Kind { get; } + + /// + /// Executes the node. + /// + ValueTask ExecuteAsync(TContext context, CancellationToken cancellationToken = default); +} diff --git a/src/TaskPipeline/Abstractions/IPipelineStep.cs b/src/TaskPipeline/Abstractions/IPipelineStep.cs new file mode 100644 index 0000000..db79166 --- /dev/null +++ b/src/TaskPipeline/Abstractions/IPipelineStep.cs @@ -0,0 +1,18 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Represents a single strongly typed step. +/// +/// Pipeline context type. +public interface IPipelineStep +{ + /// + /// Gets the step name used in diagnostics and execution results. + /// + string Name { get; } + + /// + /// Executes the step. + /// + ValueTask ExecuteAsync(TContext context, CancellationToken cancellationToken); +} diff --git a/src/TaskPipeline/Abstractions/NodeExecutionResult.cs b/src/TaskPipeline/Abstractions/NodeExecutionResult.cs new file mode 100644 index 0000000..0dc55f2 --- /dev/null +++ b/src/TaskPipeline/Abstractions/NodeExecutionResult.cs @@ -0,0 +1,73 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Detailed result for a single executed node. +/// +public sealed record NodeExecutionResult +{ + /// + /// Friendly node name. + /// + public required string Name { get; init; } + + /// + /// Node kind. + /// + public required NodeKind Kind { get; init; } + + /// + /// Final execution status. + /// + public required ExecutionStatus Status { get; init; } + + /// + /// Start timestamp in UTC. + /// + public required DateTimeOffset StartedAtUtc { get; init; } + + /// + /// Total execution duration. + /// + public required TimeSpan Duration { get; init; } + + /// + /// Exception raised during execution, when applicable. + /// + public Exception? Exception { get; init; } + + /// + /// Arbitrary metadata that callers can use for diagnostics. + /// + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); + + /// + /// Child results. Branches and sequences populate this collection. + /// + public IReadOnlyList Children { get; init; } = []; + + /// + /// Creates a lightweight result instance. + /// + public static NodeExecutionResult Create( + string name, + NodeKind kind, + ExecutionStatus status, + DateTimeOffset startedAtUtc, + TimeSpan duration, + Exception? exception = null, + IReadOnlyDictionary? metadata = null, + IReadOnlyList? children = null) + { + return new NodeExecutionResult + { + Name = name, + Kind = kind, + Status = status, + StartedAtUtc = startedAtUtc, + Duration = duration, + Exception = exception, + Metadata = metadata ?? new Dictionary(), + Children = children ?? [] + }; + } +} diff --git a/src/TaskPipeline/Abstractions/NodeKind.cs b/src/TaskPipeline/Abstractions/NodeKind.cs new file mode 100644 index 0000000..0649511 --- /dev/null +++ b/src/TaskPipeline/Abstractions/NodeKind.cs @@ -0,0 +1,14 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Describes the semantic role of a pipeline node. +/// +public enum NodeKind +{ + Pipeline, + Sequence, + Step, + Conditional, + Fork, + Branch +} diff --git a/src/TaskPipeline/Abstractions/PipelineExecutionResult.cs b/src/TaskPipeline/Abstractions/PipelineExecutionResult.cs new file mode 100644 index 0000000..cd9a992 --- /dev/null +++ b/src/TaskPipeline/Abstractions/PipelineExecutionResult.cs @@ -0,0 +1,95 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Final pipeline result including the root node and flattened accessors. +/// +public sealed record PipelineExecutionResult +{ + /// + /// Pipeline name. + /// + public required string Name { get; init; } + + /// + /// Overall pipeline status. + /// + public required ExecutionStatus Status { get; init; } + + /// + /// Duration from the start of the pipeline until completion. + /// + public required TimeSpan Duration { get; init; } + + /// + /// Root result node that contains the entire execution tree. + /// + public required NodeExecutionResult Root { get; init; } + + /// + /// Returns failed terminal nodes in depth-first deterministic order. + /// Aggregate containers such as the pipeline root or a wrapping sequence are excluded + /// when they only mirror the failure of nested nodes. + /// + public IReadOnlyList FailedNodes => FlattenTerminalStatuses(Root, ExecutionStatus.Failed) + .ToArray(); + + /// + /// Returns cancelled terminal nodes in depth-first deterministic order. + /// Aggregate containers such as the pipeline root or a wrapping sequence are excluded + /// when they only mirror the cancellation of nested nodes. + /// + public IReadOnlyList CancelledNodes => FlattenTerminalStatuses(Root, ExecutionStatus.Cancelled) + .ToArray(); + + private static IEnumerable FlattenTerminalStatuses(NodeExecutionResult root, ExecutionStatus status) + { + foreach (var result in Flatten(root)) + { + if (result.Status != status) + { + continue; + } + + // Composite/container nodes often reflect the aggregated status of nested nodes. + // The flattened accessors are intended to surface actionable nodes, so we skip + // wrappers that already expose matching descendants. + if (result.Children.Any(child => ContainsStatus(child, status))) + { + continue; + } + + yield return result; + } + } + + private static bool ContainsStatus(NodeExecutionResult node, ExecutionStatus status) + { + if (node.Status == status) + { + return true; + } + + foreach (var child in node.Children) + { + if (ContainsStatus(child, status)) + { + return true; + } + } + + return false; + } + + private static IEnumerable Flatten(NodeExecutionResult root) + { + yield return root; + + foreach (var child in root.Children) + { + foreach (var nested in Flatten(child)) + { + yield return nested; + } + } + } +} diff --git a/src/TaskPipeline/Abstractions/PipelineFailureMode.cs b/src/TaskPipeline/Abstractions/PipelineFailureMode.cs new file mode 100644 index 0000000..8230845 --- /dev/null +++ b/src/TaskPipeline/Abstractions/PipelineFailureMode.cs @@ -0,0 +1,10 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Defines how the pipeline reacts to failures. +/// +public enum PipelineFailureMode +{ + FailFast, + ContinueOnError +} diff --git a/src/TaskPipeline/Abstractions/PipelineNodeExecutionContext.cs b/src/TaskPipeline/Abstractions/PipelineNodeExecutionContext.cs new file mode 100644 index 0000000..3e1f5c0 --- /dev/null +++ b/src/TaskPipeline/Abstractions/PipelineNodeExecutionContext.cs @@ -0,0 +1,11 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Supplies metadata to behaviors running around a node. +/// +/// Pipeline context type. +public sealed record PipelineNodeExecutionContext( + string PipelineName, + string NodeName, + NodeKind NodeKind, + TContext Context); diff --git a/src/TaskPipeline/Abstractions/PipelineOptions.cs b/src/TaskPipeline/Abstractions/PipelineOptions.cs new file mode 100644 index 0000000..6747743 --- /dev/null +++ b/src/TaskPipeline/Abstractions/PipelineOptions.cs @@ -0,0 +1,12 @@ +namespace TaskPipeline.Abstractions; + +/// +/// Controls global execution behavior for a pipeline instance. +/// +public sealed record PipelineOptions +{ + /// + /// How the pipeline reacts to failures raised by steps or branches. + /// + public PipelineFailureMode FailureMode { get; init; } = PipelineFailureMode.FailFast; +} diff --git a/src/TaskPipeline/TaskPipeline.csproj b/src/TaskPipeline/TaskPipeline.csproj index f4336c9..91634e4 100644 --- a/src/TaskPipeline/TaskPipeline.csproj +++ b/src/TaskPipeline/TaskPipeline.csproj @@ -13,9 +13,6 @@ pipeline orchestration task workflow dotnet - - - - + diff --git a/tests/TaskPipeline.Tests/TaskPipeline.Tests.csproj b/tests/TaskPipeline.Tests/TaskPipeline.Tests.csproj index d67e338..4436a0d 100644 --- a/tests/TaskPipeline.Tests/TaskPipeline.Tests.csproj +++ b/tests/TaskPipeline.Tests/TaskPipeline.Tests.csproj @@ -17,6 +17,5 @@ -