Compare commits

..

8 Commits

Author SHA1 Message Date
52ba2fc242 fix(e2e): await the decide POST before leaving the werkbak page (refs #13)
All checks were successful
CI / lint (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 2m31s
CI / mutation (pull_request) Successful in 4m57s
CI / verify-stack (pull_request) Successful in 6m55s
The happy path clicked Goedkeuren and immediately navigated to the openbaar register, which

cancelled the in-flight POST /behandel/registrations/{id}/decide (nginx 499) — the decision

never reached the domain, so the registration stayed INGEDIEND and the final INGESCHREVEN

assertion failed. Wait for the decide response (204) before navigating away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:12:50 +02:00
2c88cb0db0 docs(behandel): frontend decision log + demo-script for the behandel portal (refs #13)
Some checks failed
CI / lint (pull_request) Successful in 1m20s
CI / build (pull_request) Successful in 59s
CI / unit (pull_request) Successful in 1m12s
CI / frontend (pull_request) Successful in 6m36s
CI / mutation (pull_request) Successful in 5m10s
CI / verify-stack (pull_request) Failing after 5m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:17:11 +02:00
4e792c2d16 build(infra): serve the behandel portal in compose and gate it in the smoke (refs #13)
Add the behandel service (:8142, nginx → BFF, depends on Keycloak for the medewerker realm) to

docker-compose, add it to the smoke's WAIT_SVCS and the CI failure-log dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:17:11 +02:00
7b327a5601 test(e2e): approve via the behandel-portal werkbak instead of the admin endpoint (refs #13)
The walking-skeleton happy path now drives the real S-12 flow: after submit, a behandelaar logs

in to the behandel portal (medewerker realm), finds the registration in the werkbak by its

reference, and clicks Goedkeuren — replacing the temporary domain admin endpoint. Treat the

behandel origin as secure too, for its PKCE login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:17:11 +02:00
b4c4ffcd19 feat(behandel): behandel-portal app — medewerker login + werkbak with decide (refs #13)
Scaffold apps/behandel (mirrors self-service): medewerker-realm OIDC login, the werkbak page

listing registrations awaiting beoordeling (GET /behandel/werkbak) with goedkeuren/afwijzen

actions (POST /behandel/registrations/{id}/decide) that refresh the list, plus Dockerfile and

same-origin nginx reverse proxy for /behandel/ (ADR-0013; S-12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:11:04 +02:00
76fe414802 test(behandel): werkbak lists cases, decides them, and secures the BFF calls (refs #13)
Drives the behandel-portal frontend: the werkbak lists registrations awaiting beoordeling, a

decision (goedkeuren/afwijzen) posts to the BFF and refreshes, an empty werkbak and a load

failure are surfaced, the page is WCAG 2.1 AA clean, and the medewerker token attaches to the

relative /behandel/ calls (and not to the anonymous openbaar register).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:10:56 +02:00
a88584514c feat(auth): medewerker-realm AuthService + provider, roles surface, realm-roles mapper (refs #13)
The behandel-portal authenticates staff against the Keycloak `medewerker` realm. Add

MedewerkerAuthService (reads nested realm_access.roles), provideMedewerkerAuth, a roles/hasRole

surface on the shared AuthService, and a realm-roles protocol mapper so the frontend can read

the behandelaar/teamlead roles from the token. Per ADR-0013 the BFF remains the security boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:05:45 +02:00
1d12d693ce test(auth): MedewerkerAuthService exposes realm roles from the medewerker token (refs #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:05:45 +02:00
16 changed files with 12 additions and 525 deletions

View File

@@ -1,72 +0,0 @@
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Respellion engineering
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
(behandel-portal wiring, the Beoordelen user task)
## Context
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
Two questions shape this sub-slice.
1. **How does the case get cancelled — in code, or in the BPMN model?**
2. **How does a withdrawal correlate to the right running process instance?**
## Decision
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
Targeting the subscribed execution (not the user task's execution — a message boundary event's
subscription lives on its own execution) is what makes the correlation land.
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
completes its task best-effort.
## Consequences
**Positive**
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
that talks to Flowable and does not delete instances behind the model's back.
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
**Negative / costs**
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
reference at these volumes; a process-level interrupting event subprocess would close the gap and
is an additive follow-up if it matters.
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
(consistent with ADR-0009).
## Alternatives considered
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
not the process. The message event keeps the cancellation a first-class part of the workflow.
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
change if the race proves to matter.

View File

@@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
fl_base="http://$(ip "$fl"):8080/flowable-rest/service" fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}" reg_id="${loc##*/}"
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on # Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. # Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
try: try:
d=json.load(sys.stdin) d=json.load(sys.stdin)
except Exception: except Exception:
@@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id="" task_id=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")" task_id="$(printf '%s' "$resp" | task_for_reg)"
[ -n "$task_id" ] && break [ -n "$task_id" ] && break
sleep 2 sleep 2
done done
@@ -115,44 +115,7 @@ flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/j
echo ">> asserting the process finished (no Beoordelen task remains for the registration)" echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" still="$(printf '%s' "$resp" | task_for_reg)"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
echo ">> submitting a second registration to withdraw"
loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
echo ">> polling Flowable for its Beoordelen task"
task_id2=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -n "$task_id2" ] && break
sleep 2
done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -z "$still2" ] && { gone=1; break; }
sleep 2
done
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
exit 0 exit 0

View File

@@ -26,7 +26,6 @@ builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>(); builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>(); builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<Werkbak>(); builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -75,19 +74,6 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
return Results.NoContent(); return Results.NoContent();
}); });
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
// process is a later sub-slice (S-11b).
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
return Results.NoContent();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open // The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind // Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3). // medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).

View File

@@ -15,14 +15,6 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id. /// aggregate. Returns the process instance id.
/// </summary> /// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
} }
/// <summary> /// <summary>

View File

@@ -1,5 +1,3 @@
using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the /// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
@@ -10,8 +8,7 @@ public sealed record WerkbakItem(string RegistrationId, string Bsn, string Statu
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the /// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) — /// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task /// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
/// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling /// whose registration the domain doesn't know is skipped rather than invented.
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
/// </summary> /// </summary>
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
{ {
@@ -23,7 +20,7 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
foreach (var task in open) foreach (var task in open)
{ {
var registration = await store.GetAsync(task.RegistrationId, ct); var registration = await store.GetAsync(task.RegistrationId, ct);
if (registration is null || !IsOpenForBeoordeling(registration.Status)) if (registration is null)
continue; continue;
items.Add(new WerkbakItem( items.Add(new WerkbakItem(
@@ -32,9 +29,4 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
return items; return items;
} }
// Only registrations still open for a decision belong in the werkbak; a terminal one (decided or
// withdrawn — S-11) whose Beoordelen task has not yet been cleared must not surface to a behandelaar.
private static bool IsOpenForBeoordeling(RegistrationStatus status)
=> status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling;
} }

View File

@@ -1,37 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// by correlating the withdrawal message to its instance (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 the
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
{
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
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 or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return;
registration.Withdraw();
await store.SaveAsync(registration, ct);
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
if (registration.ProcessInstanceId is not null)
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
}
}

View File

@@ -111,23 +111,7 @@ public sealed class Registration
Status = RegistrationStatus.Afgewezen; Status = RegistrationStatus.Afgewezen;
} }
/// <summary> // A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
/// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed
/// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has
/// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one
/// already <see cref="RegistrationStatus.Ingetrokken"/> is a no-op.
/// </summary>
public void Withdraw()
{
if (Status == RegistrationStatus.Ingetrokken)
return;
RequireOpenForDecision(nameof(Withdraw));
Status = RegistrationStatus.Ingetrokken;
}
// A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or
// IN_BEHANDELING).
private void RequireOpenForDecision(string decision) private void RequireOpenForDecision(string decision)
{ {
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)) if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))

View File

@@ -3,8 +3,7 @@ namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in /// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one /// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected). /// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
/// A zorgprofessional can withdraw a still-open registration into <see cref="Ingetrokken"/> (S-11). /// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
/// The herregistratie state arrives in its own slice.</summary>
public enum RegistrationStatus public enum RegistrationStatus
{ {
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary> /// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
@@ -18,7 +17,4 @@ public enum RegistrationStatus
/// <summary>Rejected by the behandelaar. Terminal.</summary> /// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen, Afgewezen,
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
Ingetrokken,
} }

View File

@@ -23,7 +23,6 @@ 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)
{ {
@@ -86,35 +85,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the
// interrupting message event's own execution — NOT the user task's), then deliver
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
var subscribed = await GetAsync<ExecutionQueryResult>(
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
ct);
var execution = subscribed?.Data?.FirstOrDefault();
if (execution is null)
return;
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
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);
@@ -122,9 +92,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, HttpMethod? method = null) private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
{ {
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path)) var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{ {
Content = JsonContent.Create(body), Content = JsonContent.Create(body),
}; };
@@ -162,10 +132,6 @@ 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);
@@ -188,11 +154,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredJob( private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)

View File

@@ -32,7 +32,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient : IWorkflowClient
{ {
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -40,12 +39,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(processInstanceId); return Task.FromResult(processInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of /// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of

View File

@@ -242,61 +242,6 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{expected}\"", capture.Body); Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
} }
[Fact]
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
{
HttpRequestMessage? getReq = null;
HttpRequestMessage? putReq = null;
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.WithdrawProcessAsync("pi-1");
// 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Equal(HttpMethod.Put, putReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
}
[Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
{
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
}
[Fact] [Fact]
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
{ {

View File

@@ -217,72 +217,4 @@ public class RegistrationTests
Assert.Contains("Afgewezen", rejectEx.Message); Assert.Contains("Afgewezen", rejectEx.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
} }
[Fact]
public void Withdrawing_an_ingediend_registration_sets_it_ingetrokken()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_in_behandeling_registration_sets_it_ingetrokken()
{
// A citizen can still pull a registration back while a behandelaar has it in behandeling.
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_needs_no_zaak()
{
// Withdrawal is the citizen's own action and does not depend on the zaak having been opened.
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_approved_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Withdrawing_a_rejected_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
} }

View File

@@ -46,19 +46,4 @@ public class WerkbakTests
Assert.Empty(await werkbak.GetAsync()); Assert.Empty(await werkbak.GetAsync());
} }
[Fact]
public async Task Skips_a_task_whose_registration_is_no_longer_open()
{
// A withdrawn registration (S-11) may still have a Beoordelen task lingering until the workflow
// cancels it; the werkbak lists only registrations still open for beoordeling, so it drops off.
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
registration.Withdraw();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var werkbak = new Werkbak(tasks, store);
Assert.Empty(await werkbak.GetAsync());
}
} }

View File

@@ -1,100 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
public class WithdrawRegistrationTests
{
private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit("123456782");
registration.RecordProcessStarted(processInstanceId);
return registration;
}
[Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Withdrawing_cancels_the_running_process()
{
var store = new FakeRegistrationStore();
var registration = Submitted("proc-42");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The process the registration recorded at submit is cancelled (ADR-0014).
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
{
// A registration with no recorded process (e.g. start never happened) can still be withdrawn;
// there is simply no process to cancel.
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782"); // no RecordProcessStarted
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Withdrawing_an_unknown_registration_throws()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The second withdrawal is a no-op: the aggregate is not persisted again.
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -11,19 +11,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
public const string StartedProcessInstanceId = "proc-acc-1"; public const string StartedProcessInstanceId = "proc-acc-1";
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(StartedProcessInstanceId); return Task.FromResult(StartedProcessInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, /// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,

View File

@@ -11,13 +11,7 @@
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"/>
@@ -35,16 +29,6 @@
<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">
@@ -73,16 +57,6 @@
<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>