From edffbb4575d8f164466f9fc68a26870eb949f71b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:29:56 +0200 Subject: [PATCH] feat(domain): withdrawal cancels the registratie process via a BPMN message event (refs #12) Add an interrupting message boundary event (RegistratieIngetrokken) on the Beoordelen task that ends the process; the Workflow Client delivers the message to the task's execution, and the withdraw handler triggers it best-effort after the domain transition (ADR-0014). Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Application/Ports.cs | 11 ++++++-- .../Big.Application/WithdrawRegistration.cs | 26 ++++++++++++----- .../FlowableWorkflowClient.cs | 24 ++++++++++++++-- workflows/registratie.bpmn | 28 ++++++++++++++++++- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 0859d7c..b75a805 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -54,11 +54,18 @@ public interface IUserTaskClient /// 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); + + /// Deliver the withdrawal message to a Beoordelen task's execution, tripping the + /// process's interrupting message boundary event so the registratie process cancels (S-11, + /// ADR-0014). The registration itself is already INGETROKKEN in the domain; this ends its + /// workflow so the case leaves the werkbak. + Task WithdrawBeoordelingAsync(string executionId, 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); +/// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary +/// event), and the registration it carries as a process variable. +public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId); /// /// Persistence port for the aggregate. In-memory for the minimal slice diff --git a/services/domain/Big.Application/WithdrawRegistration.cs b/services/domain/Big.Application/WithdrawRegistration.cs index 282d35a..b218efc 100644 --- a/services/domain/Big.Application/WithdrawRegistration.cs +++ b/services/domain/Big.Application/WithdrawRegistration.cs @@ -7,13 +7,14 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); /// /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It -/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered -/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again). -/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later -/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only — -/// mirroring how the beoordeling's rejection deferred its zaak propagation. +/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process +/// by delivering the withdrawal message to its open Beoordelen task (ADR-0014), so the case +/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an +/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is +/// best-effort: if no Beoordelen task is open (the process has not parked there yet, or has +/// already ended) the withdrawal still stands — mirroring . /// -public sealed class WithdrawRegistration(IRegistrationStore store) +public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks) { public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) { @@ -22,11 +23,22 @@ public sealed class WithdrawRegistration(IRegistrationStore store) var registration = await store.GetAsync(command.RegistrationId, ct) ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw."); - // A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again. + // A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again. if (registration.Status == RegistrationStatus.Ingetrokken) return; registration.Withdraw(); await store.SaveAsync(registration, ct); + await CancelWorkflowTaskAsync(command.RegistrationId, ct); + } + + // Cancel the workflow: deliver the withdrawal message to the open Beoordelen task's execution so + // the process's boundary event ends it. If none is open the withdrawal still stands. + private async Task CancelWorkflowTaskAsync(RegistrationId registrationId, CancellationToken ct) + { + var open = await tasks.GetOpenBeoordelingenAsync(ct); + var task = open.FirstOrDefault(t => t.RegistrationId == registrationId); + if (task is not null) + await tasks.WithdrawBeoordelingAsync(task.ExecutionId, ct); } } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index f782cea..5aa8533 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -23,6 +23,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; private const string BesluitVariable = "besluit"; + private const string IngetrokkenMessage = "RegistratieIngetrokken"; public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -65,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti "service/query/tasks", request, ct); var tasks = page?.Data ?? []; - return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; + return [.. tasks.Select(t => new BeoordelingTask(t.Id, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))]; } public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) @@ -85,6 +86,18 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) + { + // Deliver the withdrawal message to the Beoordelen task's execution, tripping the process's + // interrupting message boundary event so the registratie instance ends (ADR-0014). Flowable + // takes messageEventReceived as a PUT on the subscribed execution. + var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage); + + using var response = await SendAsync( + $"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put); + response.EnsureSuccessStatusCode(); + } + private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); @@ -92,9 +105,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti return await response.Content.ReadFromJsonAsync(ct); } - private Task SendAsync(string path, TRequest body, CancellationToken ct) + private Task SendAsync(string path, TRequest body, CancellationToken ct, HttpMethod? method = null) { - var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) + var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path)) { Content = JsonContent.Create(body), }; @@ -132,11 +145,16 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti [property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("variables")] IReadOnlyList Variables); + private sealed record MessageEventRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("messageName")] string MessageName); + private sealed record TaskQueryResult( [property: JsonPropertyName("data")] IReadOnlyList? Data); private sealed record UserTaskDto( [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("executionId")] string ExecutionId, // Flowable's task-query returns the (included) process variables under "variables", not // "processVariables"; the request opts in via includeProcessVariables. [property: JsonPropertyName("variables")] IReadOnlyList? Variables) diff --git a/workflows/registratie.bpmn b/workflows/registratie.bpmn index 51f7de9..99a7ec7 100644 --- a/workflows/registratie.bpmn +++ b/workflows/registratie.bpmn @@ -11,7 +11,13 @@ S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling as a user task: the process parks here until a behandelaar claims and completes it with a `besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process - variable so the werkbak can correlate each task back to its aggregate. --> + variable so the werkbak can correlate each task back to its aggregate. + S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an + interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the + process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message + to the task's execution; the BPMN owns the cancellation path. --> + + @@ -29,6 +35,16 @@ + + + + + + + + + @@ -57,6 +73,16 @@ + + + + + + + + + +