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
6 changed files with 2 additions and 196 deletions

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

@@ -1,32 +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 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.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store)
{
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 the already-withdrawn aggregate again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return;
registration.Withdraw();
await store.SaveAsync(registration, 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

@@ -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

@@ -1,60 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
public class WithdrawRegistrationTests
{
[Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var handler = new WithdrawRegistration(store);
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 Rejects_a_null_command_without_touching_the_store()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store);
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);
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 = Registration.Submit("123456782");
store.Seed(registration);
var handler = new WithdrawRegistration(store);
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);
}
}