From efd1c06b99d69368a7972b81e73f01aa40c28389 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 15 Jul 2026 09:18:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20user-task=20client=20for=20be?= =?UTF-8?q?oordeling=20=E2=80=94=20werkbak,=20claim,=20complete=20(refs=20?= =?UTF-8?q?#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the IUserTaskClient port and implements it on the Workflow Client (the only code that talks to Flowable, §8.2): query open Beoordelen tasks (werkbak) with their registrationId, claim a task for a behandelaar, and complete it carrying the besluit into the process. Registered in DI for later wiring (S-12c). Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Api/Program.cs | 1 + services/domain/Big.Application/Ports.cs | 24 ++++++++ .../FlowableWorkflowClient.cs | 58 ++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index eb26a17..d24d5c6 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(); builder.Services.AddScoped(); diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 12350c6..0859d7c 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -36,6 +36,30 @@ public interface IAclClient Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); } +/// +/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the +/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open +/// Beoordelen tasks (the werkbak), claims one for a behandelaar, and completes it with the +/// decision; it never names Flowable's REST shapes. +/// +public interface IUserTaskClient +{ + /// The werkbak: the Beoordelen tasks awaiting a behandelaar, each with the + /// registration it belongs to. + Task> GetOpenBeoordelingenAsync(CancellationToken ct = default); + + /// Claim a beoordeling task for a behandelaar (assigns it to them). + Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default); + + /// Complete a beoordeling task, carrying the decision into the process as the + /// besluit variable so the workflow can continue on the chosen branch. + Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default); +} + +/// A Beoordelen user task in the werkbak: the Flowable task id (needed to claim and +/// complete it) and the registration it carries as a process variable. +public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId); + /// /// Persistence port for the aggregate. In-memory for the minimal slice /// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive. diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index 0b6ba23..323ede0 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -15,12 +15,14 @@ namespace Big.Infrastructure; /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) - : IWorkflowClient, IExternalWorkerClient + : IWorkflowClient, IExternalWorkerClient, IUserTaskClient { private const string Topic = "OpenZaakAanmaken"; private const string ProcessDefinitionKey = "registratie"; + private const string BeoordelenTaskKey = "Beoordelen"; private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; + private const string BesluitVariable = "besluit"; public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public async Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) + { + var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true); + + var page = await PostAsync( + "service/runtime/tasks/query", request, ct); + + var tasks = page?.Data ?? []; + return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; + } + + public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) + { + using var response = await SendAsync( + $"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct); + response.EnsureSuccessStatusCode(); + } + + public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default) + { + var request = new CompleteTaskRequest( + "complete", + [new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]); + + using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct); + response.EnsureSuccessStatusCode(); + } + private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); @@ -89,6 +119,32 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti [property: JsonPropertyName("workerId")] string WorkerId, [property: JsonPropertyName("variables")] IReadOnlyList Variables); + private sealed record TaskQueryRequest( + [property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey, + [property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey, + [property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables); + + private sealed record ClaimTaskRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("assignee")] string Assignee); + + private sealed record CompleteTaskRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("variables")] IReadOnlyList Variables); + + private sealed record TaskQueryResult( + [property: JsonPropertyName("data")] IReadOnlyList? Data); + + private sealed record UserTaskDto( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("processVariables")] IReadOnlyList? ProcessVariables) + { + /// The registration id this task carries as a process variable. + public string RegistrationId() => + ProcessVariables?.SingleOrDefault(v => v.Name == "registrationId")?.Value + ?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable."); + } + private sealed record Variable( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("type")] string Type,