feat(#13): S-12c-2 — behandel decide → domain + complete workflow task (#86)
All checks were successful
CI / lint (push) Successful in 1m17s
CI / build (push) Successful in 1m0s
CI / unit (push) Successful in 1m8s
CI / frontend (push) Successful in 2m20s
CI / mutation (push) Successful in 4m57s
CI / verify-stack (push) Successful in 7m3s

## What & why

Second half of **S-12c** (behandel-portal backend), completing the decision path per **ADR-0013**:

- **Domain:** `BeoordeelRegistratie` now, after applying the decision (aggregate + ACL for approval), **completes the open Flowable `Beoordelen` task** for that registration (found by registrationId) with the besluit, so the workflow advances. No open task → the decision still stands (completes nothing); idempotent.
- **BFF:** `POST /behandel/registrations/{id}/decide` behind the medewerker/`behandelaar` policy, forwarding `goedkeuren`/`afwijzen` to the domain. Validates the besluit vocabulary (400 on unknown) without troubling the domain.

Behavior: decide is **401** without a token, **403** without the role, **400** for an unknown besluit, **204** (forwarded) for a behandelaar.

This completes the behandel backend. **S-12d** (the Angular behandel-portal + Playwright e2e) closes umbrella #13 and retires the temporary `/approve`.

## Definition of Done

- [x] Linked issue: #13 (umbrella, `refs`)
- [x] Tests first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 79, bff 27, acceptance 9 (acl/event-subscriber unaffected)
- [x] Beoordeling acceptance scenario asserts task completion (goedkeuren + afwijzen)
- [x] openapi.json + api-client regenerated (drift guard passes)
- [x] Mutation ≥ break(90): **domain 100%, bff 100%**
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
2026-07-16 06:53:52 +00:00
parent 9c3da48d8e
commit d226b6402d
12 changed files with 284 additions and 14 deletions

View File

@@ -24,6 +24,9 @@ public interface IDomainClient
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
}
/// <summary>Port to the read projection.</summary>
@@ -47,6 +50,13 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/decide", new { besluit }, ct);
response.EnsureSuccessStatusCode();
}
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
}

View File

@@ -103,8 +103,28 @@ app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken c
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
app.MapPost("/behandel/registrations/{id}/decide",
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
{
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await domain.DecideAsync(id, body.Besluit, ct);
return Results.NoContent();
})
.RequireAuthorization(BehandelAuth.Policy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
public sealed record DecideRequest(string Besluit);
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
internal static class BehandelAuth
{
@@ -112,6 +132,12 @@ internal static class BehandelAuth
public const string Policy = "behandelaar";
public const string BehandelaarRole = "behandelaar";
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
public static bool IsKnownBesluit(string? besluit) =>
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
public static void AddRealmRoles(ClaimsPrincipal? principal)