diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index ca0d967..ca1d1c3 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -24,6 +24,10 @@ import { Observable } from 'rxjs'; +export interface DecideRequest { + besluit: string; +} + export interface OpenbaarEntry { id: string; status: string; @@ -252,4 +256,42 @@ export class BffApiV1Service { ); } + postBehandelRegistrationsIdDecide(id: string, + decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable; + postBehandelRegistrationsIdDecide(id: string, + decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable>; + postBehandelRegistrationsIdDecide(id: string, + decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable>; + postBehandelRegistrationsIdDecide( + id: string, + decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/behandel/registrations/${id}/decide`, + decideRequest,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/behandel/registrations/${id}/decide`, + decideRequest,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/behandel/registrations/${id}/decide`, + decideRequest,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + }; diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 8571641..5c21c85 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -24,6 +24,9 @@ public interface IDomainClient /// The behandelaar's werkbak — registrations awaiting beoordeling. Task> GetWerkbakAsync(CancellationToken ct = default); + + /// Apply a behandelaar's decision (goedkeuren/afwijzen) to a registration. + Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default); } /// Port to the read projection. @@ -47,6 +50,13 @@ public sealed class DomainClient(HttpClient http) : IDomainClient public async Task> GetWerkbakAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("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); } diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 871ae31..1f95d04 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -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(); +/// The behandelaar's decision on a registration. +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"; + /// The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a + /// 400 without troubling the domain. Mirrors the domain's BeoordelingsBesluit. + public static bool IsKnownBesluit(string? besluit) => + string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) || + string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase); + /// Lift Keycloak's realm roles (the nested realm_access.roles claim) onto the /// principal as role claims, so RequireRole can authorize on them. public static void AddRealmRoles(ClaimsPrincipal? principal) diff --git a/services/bff/Bff.Tests/BehandelEndpointTests.cs b/services/bff/Bff.Tests/BehandelEndpointTests.cs index 3d39147..c3e3dfb 100644 --- a/services/bff/Bff.Tests/BehandelEndpointTests.cs +++ b/services/bff/Bff.Tests/BehandelEndpointTests.cs @@ -54,4 +54,61 @@ public class BehandelEndpointTests Assert.Equal("reg-1", item.RegistrationId); Assert.Equal("123456782", item.Bsn); } + + private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren") + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide") + { + Content = JsonContent.Create(new { besluit }), + }; + if (bearer is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return request; + } + + [Fact] + public async Task Rejects_a_decision_without_a_token() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Decide(bearer: null)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Null(factory.Domain.Decided); + } + + [Fact] + public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead"))); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(factory.Domain.Decided); + } + + [Fact] + public async Task Forwards_a_behandelaar_decision_to_the_domain() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient() + .SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen")); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided); + } + + [Fact] + public async Task Rejects_an_unknown_besluit_without_calling_the_domain() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient() + .SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Null(factory.Domain.Decided); + } } diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 5e74fb9..3cf1e67 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -82,8 +82,16 @@ internal sealed class FakeDomainClient : IDomainClient return Task.FromResult(Result); } + public (string RegistrationId, string Besluit)? Decided { get; private set; } + public Task> GetWerkbakAsync(CancellationToken ct = default) => Task.FromResult>(Werkbak); + + public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default) + { + Decided = (registrationId, besluit); + return Task.CompletedTask; + } } /// Serves a configurable set of projection rows. diff --git a/services/bff/openapi.json b/services/bff/openapi.json index e3591c3..272d9e3 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -88,10 +88,62 @@ } } } + }, + "/behandel/registrations/{id}/decide": { + "post": { + "tags": [ + "Bff.Api" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecideRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + } } }, "components": { "schemas": { + "DecideRequest": { + "required": [ + "besluit" + ], + "type": "object", + "properties": { + "besluit": { + "type": "string" + } + } + }, "OpenbaarEntry": { "required": [ "id", diff --git a/services/domain/Big.Application/BeoordeelRegistratie.cs b/services/domain/Big.Application/BeoordeelRegistratie.cs index f0a384f..8ba8e3f 100644 --- a/services/domain/Big.Application/BeoordeelRegistratie.cs +++ b/services/domain/Big.Application/BeoordeelRegistratie.cs @@ -20,10 +20,12 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, /// sets the zaak's final status via the ACL (§8.1) and /// advances the aggregate to INGESCHREVEN; advances it to /// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects -/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered -/// decision that matches the current terminal state is a no-op, so the ACL is not called twice. +/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable +/// Beoordelen task (found by registrationId) so the workflow advances (ADR-0013). Both +/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal +/// state is a no-op, so the ACL is not called and the task not completed twice. /// -public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl) +public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks) { public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default) { @@ -53,5 +55,17 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac } await store.SaveAsync(registration, ct); + await CompleteWorkflowTaskAsync(command.RegistrationId, command.Besluit, ct); + } + + // Advance the workflow: complete the open Beoordelen task for this registration. If none is open + // (already completed, or the process hasn't parked yet) the decision still stands — we complete + // nothing rather than fail. + private async Task CompleteWorkflowTaskAsync(RegistrationId registrationId, BeoordelingsBesluit besluit, CancellationToken ct) + { + var open = await tasks.GetOpenBeoordelingenAsync(ct); + var task = open.FirstOrDefault(t => t.RegistrationId == registrationId); + if (task is not null) + await tasks.CompleteBeoordelingAsync(task.TaskId, besluit, ct); } } diff --git a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs index de51af2..9ad48de 100644 --- a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs +++ b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs @@ -6,8 +6,8 @@ namespace Big.Tests; /// /// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the /// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it -/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are -/// idempotent so a repeated or redelivered decision is a no-op. +/// AFGEWEZEN. Either way the decision also completes the Flowable Beoordelen task (found by +/// registrationId) so the process advances. All idempotent — a repeated decision is a no-op. /// public class BeoordeelRegistratieTests { @@ -18,6 +18,9 @@ public class BeoordeelRegistratieTests return registration; } + private static FakeUserTaskClient TaskFor(Registration registration) => + new([new BeoordelingTask("task-1", registration.Id)]); + [Fact] public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() { @@ -25,7 +28,8 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl); + var tasks = TaskFor(registration); + var handler = new BeoordeelRegistratie(store, acl, tasks); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); @@ -34,6 +38,8 @@ public class BeoordeelRegistratieTests Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl); Assert.Equal(1, acl.ApproveCallCount); Assert.Equal(1, store.SaveCount); + // The behandelaar's decision advances the workflow: the Beoordelen task is completed. + Assert.Equal(("task-1", BeoordelingsBesluit.Goedkeuren), tasks.Completed); } [Fact] @@ -43,7 +49,8 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl); + var tasks = TaskFor(registration); + var handler = new BeoordeelRegistratie(store, acl, tasks); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); @@ -51,6 +58,7 @@ public class BeoordeelRegistratieTests Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status); Assert.Equal(0, acl.ApproveCallCount); Assert.Equal(1, store.SaveCount); + Assert.Equal(("task-1", BeoordelingsBesluit.Afwijzen), tasks.Completed); } [Fact] @@ -61,7 +69,7 @@ public class BeoordeelRegistratieTests var registration = WithZaak(); registration.TakeIntoBehandeling(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); @@ -73,7 +81,7 @@ public class BeoordeelRegistratieTests { var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); - var handler = new BeoordeelRegistratie(store, acl); + var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([])); await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); Assert.Equal(0, acl.ApproveCallCount); @@ -85,7 +93,7 @@ public class BeoordeelRegistratieTests { var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); - var handler = new BeoordeelRegistratie(store, acl); + var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([])); var ex = await Assert.ThrowsAsync(() => handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren))); @@ -100,7 +108,7 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = Registration.Submit("123456782"); // no zaak yet store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); var ex = await Assert.ThrowsAsync(() => handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren))); @@ -115,7 +123,7 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); @@ -131,7 +139,7 @@ public class BeoordeelRegistratieTests var acl = new FakeAclClient(); var registration = WithZaak(); store.Seed(registration); - var handler = new BeoordeelRegistratie(store, acl); + var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); @@ -139,4 +147,22 @@ public class BeoordeelRegistratieTests Assert.Equal(1, store.SaveCount); Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status); } + + [Fact] + public async Task Deciding_completes_no_task_when_none_is_open_for_the_registration() + { + // The task may already be gone (redelivery / manual completion). The decision still applies + // and simply completes nothing rather than failing. + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var tasks = new FakeUserTaskClient([]); // no open task for this registration + var handler = new BeoordeelRegistratie(store, acl, tasks); + + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); + + Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status); + Assert.Null(tasks.Completed); + } } diff --git a/tests/acceptance/Features/EenRegistratieBeoordelen.feature b/tests/acceptance/Features/EenRegistratieBeoordelen.feature index 7c94fa6..1ff6aec 100644 --- a/tests/acceptance/Features/EenRegistratieBeoordelen.feature +++ b/tests/acceptance/Features/EenRegistratieBeoordelen.feature @@ -15,6 +15,7 @@ Feature: Een registratie beoordelen When the behandelaar decides "goedkeuren" Then the registration has status "INGESCHREVEN" And the zaak's final status is set via the ACL + And the beoordeling task is completed with "goedkeuren" Scenario: Afwijzen wijst de registratie af zonder de ACL Given a submitted registration with an opened zaak @@ -22,3 +23,4 @@ Feature: Een registratie beoordelen And the behandelaar decides "afwijzen" Then the registration has status "AFGEWEZEN" And the ACL is not asked to set the zaak status + And the beoordeling task is completed with "afwijzen" diff --git a/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs b/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs index 181951d..39bfa3b 100644 --- a/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs +++ b/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs @@ -16,6 +16,7 @@ public sealed class EenRegistratieBeoordelenSteps { private readonly InMemoryAclClient _acl = new(); private readonly InMemoryRegistrationStore _store = new(); + private readonly InMemoryUserTaskClient _tasks = new(); private RegistrationId _id; [Given("a submitted registration with an opened zaak")] @@ -25,6 +26,8 @@ public sealed class EenRegistratieBeoordelenSteps registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl); await _store.SaveAsync(registration); _id = registration.Id; + // The process has parked at the Beoordelen user task awaiting the behandelaar. + _tasks.Open(_id); } [When("the behandelaar takes it into behandeling")] @@ -37,7 +40,7 @@ public sealed class EenRegistratieBeoordelenSteps [When("the behandelaar decides \"(.*)\"")] public async Task WhenTheBehandelaarDecides(string besluit) - => await new BeoordeelRegistratie(_store, _acl).HandleAsync( + => await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync( new BeoordeelRegistratieCommand(_id, Enum.Parse(besluit, ignoreCase: true))); [Then("the registration has status \"(.*)\"")] @@ -55,4 +58,8 @@ public sealed class EenRegistratieBeoordelenSteps [Then("the ACL is not asked to set the zaak status")] public void ThenTheAclIsNotAskedToSetTheZaakStatus() => Assert.Null(_acl.ApprovedZaakUrl); + + [Then("the beoordeling task is completed with \"(.*)\"")] + public void ThenTheBeoordelingTaskIsCompletedWith(string besluit) + => Assert.Equal(Enum.Parse(besluit, ignoreCase: true), _tasks.Completed?.Besluit); } diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs index 574a686..b574377 100644 --- a/tests/acceptance/Support/BffAcceptanceHost.cs +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -71,6 +71,9 @@ public sealed class CapturingDomainClient : IDomainClient public Task> GetWerkbakAsync(CancellationToken ct = default) => Task.FromResult>([]); + + public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default) + => Task.CompletedTask; } /// Serves configurable projection rows. diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index fefb2e9..b5714ef 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -43,6 +43,29 @@ public sealed class InMemoryAclClient : IAclClient } } +/// An in-memory user-task client for the beoordeling acceptance scenario: it holds one open +/// Beoordelen task per registration and records the besluit each is completed with. +public sealed class InMemoryUserTaskClient : IUserTaskClient +{ + private readonly List _open = []; + + public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } + + public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId)); + + public Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) + => Task.FromResult>(_open); + + public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) => Task.CompletedTask; + + public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default) + { + Completed = (taskId, besluit); + _open.RemoveAll(t => t.TaskId == taskId); + return Task.CompletedTask; + } +} + /// An in-memory registration store for the domain acceptance scenario. public sealed class InMemoryRegistrationStore : IRegistrationStore {