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) <noreply@anthropic.com>
This commit is contained in:
@@ -54,11 +54,18 @@ public interface IUserTaskClient
|
|||||||
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
|
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
|
||||||
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
|
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
|
||||||
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
|
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Deliver the withdrawal message to a <c>Beoordelen</c> 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.</summary>
|
||||||
|
Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
|
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
|
||||||
/// complete it) and the registration it carries as a process variable.</summary>
|
/// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary
|
||||||
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
|
/// event), and the registration it carries as a process variable.</summary>
|
||||||
|
public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
|
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
/// 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
|
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
|
||||||
/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again).
|
/// by delivering the withdrawal message to its open <c>Beoordelen</c> task (ADR-0014), so the case
|
||||||
/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later
|
/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an
|
||||||
/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only —
|
/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is
|
||||||
/// mirroring how the beoordeling's rejection deferred its zaak propagation.
|
/// best-effort: if no <c>Beoordelen</c> task is open (the process has not parked there yet, or has
|
||||||
|
/// already ended) the withdrawal still stands — mirroring <see cref="BeoordeelRegistratie"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WithdrawRegistration(IRegistrationStore store)
|
public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
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)
|
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
|
?? 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)
|
if (registration.Status == RegistrationStatus.Ingetrokken)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
registration.Withdraw();
|
registration.Withdraw();
|
||||||
await store.SaveAsync(registration, ct);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
private const string RegistrationIdVariable = "registrationId";
|
private const string RegistrationIdVariable = "registrationId";
|
||||||
private const string ZaakUrlVariable = "zaakUrl";
|
private const string ZaakUrlVariable = "zaakUrl";
|
||||||
private const string BesluitVariable = "besluit";
|
private const string BesluitVariable = "besluit";
|
||||||
|
private const string IngetrokkenMessage = "RegistratieIngetrokken";
|
||||||
|
|
||||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -65,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
"service/query/tasks", request, ct);
|
"service/query/tasks", request, ct);
|
||||||
|
|
||||||
var tasks = page?.Data ?? [];
|
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)
|
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();
|
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<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(path, body, 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<TResponse>(ct);
|
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
|
private Task<HttpResponseMessage> SendAsync<TRequest>(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),
|
Content = JsonContent.Create(body),
|
||||||
};
|
};
|
||||||
@@ -132,11 +145,16 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
[property: JsonPropertyName("action")] string Action,
|
[property: JsonPropertyName("action")] string Action,
|
||||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||||
|
|
||||||
|
private sealed record MessageEventRequest(
|
||||||
|
[property: JsonPropertyName("action")] string Action,
|
||||||
|
[property: JsonPropertyName("messageName")] string MessageName);
|
||||||
|
|
||||||
private sealed record TaskQueryResult(
|
private sealed record TaskQueryResult(
|
||||||
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
|
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
|
||||||
|
|
||||||
private sealed record UserTaskDto(
|
private sealed record UserTaskDto(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("executionId")] string ExecutionId,
|
||||||
// Flowable's task-query returns the (included) process variables under "variables", not
|
// Flowable's task-query returns the (included) process variables under "variables", not
|
||||||
// "processVariables"; the request opts in via includeProcessVariables.
|
// "processVariables"; the request opts in via includeProcessVariables.
|
||||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
|
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
|
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
|
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
|
`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. -->
|
||||||
|
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
||||||
|
|
||||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||||
|
|
||||||
<startEvent id="start" name="Registratie ontvangen"/>
|
<startEvent id="start" name="Registratie ontvangen"/>
|
||||||
@@ -29,6 +35,16 @@
|
|||||||
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
||||||
|
|
||||||
<endEvent id="end" name="Registratie beoordeeld"/>
|
<endEvent id="end" name="Registratie beoordeeld"/>
|
||||||
|
|
||||||
|
<!-- Withdrawal (S-11): interrupting message boundary event on Beoordelen. On RegistratieIngetrokken
|
||||||
|
the task is cancelled and the process ends as "ingetrokken". -->
|
||||||
|
<boundaryEvent id="Ingetrokken" attachedToRef="Beoordelen" cancelActivity="true">
|
||||||
|
<messageEventDefinition messageRef="Message_Ingetrokken"/>
|
||||||
|
</boundaryEvent>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
|
||||||
|
|
||||||
|
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
|
||||||
</process>
|
</process>
|
||||||
|
|
||||||
<bpmndi:BPMNDiagram id="diagram">
|
<bpmndi:BPMNDiagram id="diagram">
|
||||||
@@ -57,6 +73,16 @@
|
|||||||
<omgdi:waypoint x="510" y="115"/>
|
<omgdi:waypoint x="510" y="115"/>
|
||||||
<omgdi:waypoint x="580" y="115"/>
|
<omgdi:waypoint x="580" y="115"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
|
||||||
|
<omgdc:Bounds x="435" y="135" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
|
||||||
|
<omgdc:Bounds x="435" y="220" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
|
||||||
|
<omgdi:waypoint x="450" y="165"/>
|
||||||
|
<omgdi:waypoint x="450" y="220"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
</bpmndi:BPMNPlane>
|
</bpmndi:BPMNPlane>
|
||||||
</bpmndi:BPMNDiagram>
|
</bpmndi:BPMNDiagram>
|
||||||
</definitions>
|
</definitions>
|
||||||
|
|||||||
Reference in New Issue
Block a user