From 3e0d2f9b11abcadbccd9de814dc3568aa292b947 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:50:45 +0200 Subject: [PATCH] feat(domain): ApproveRegistration use case + temp /registrations/{id}/approve endpoint (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads the registration, asks the ACL to set its zaak's final status (new IAclClient.ApproveZaakAsync → POST /statussen), then advances the aggregate to INGESCHREVEN. Idempotent: a repeated approval is a no-op. Adds HTTP-adapter tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Api/Program.cs | 13 +++++++ .../Big.Application/ApproveRegistration.cs | 36 +++++++++++++++++++ services/domain/Big.Application/Ports.cs | 6 ++++ .../Big.Infrastructure/AclHttpClient.cs | 11 ++++++ .../domain/Big.Tests/AclHttpClientTests.cs | 23 ++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 services/domain/Big.Application/ApproveRegistration.cs diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 6f2599d..c180da8 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddTransient(sp => sp.GetRequiredService builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -42,6 +43,18 @@ app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegis return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); }); +// Temporary admin endpoint (S-09b): approve a registration — the behandelaar's decision, until the +// behandel-portal exists (S-12). The zaak's final status is set via the ACL, which flows back over +// NRC to the projection, making the entry publicly visible as INGESCHREVEN. Idempotent. +app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration approve, CancellationToken ct) => +{ + if (!Guid.TryParse(id, out var guid)) + return Results.NotFound(); + + await approve.HandleAsync(new ApproveRegistrationCommand(new RegistrationId(guid)), ct); + return Results.NoContent(); +}); + // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => { diff --git a/services/domain/Big.Application/ApproveRegistration.cs b/services/domain/Big.Application/ApproveRegistration.cs new file mode 100644 index 0000000..ac6e32e --- /dev/null +++ b/services/domain/Big.Application/ApproveRegistration.cs @@ -0,0 +1,36 @@ +using Big.Domain; + +namespace Big.Application; + +/// A behandelaar's decision to approve a registration, in domain language. +public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId); + +/// +/// The approve use case (S-09b): set the registration's zaak to its final status via the ACL (§8.1), +/// then advance the aggregate to INGESCHREVEN. Idempotent — a redelivered or repeated approval of an +/// already-approved registration is a no-op, so the ACL is not asked to set the status twice. The +/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition +/// keeps the domain's own view consistent. +/// +public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl) +{ + public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + + var registration = await store.GetAsync(command.RegistrationId, ct) + ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to approve."); + + // A repeated approval is a no-op: don't set the zaak status a second time. + if (registration.Status == RegistrationStatus.Ingeschreven) + return; + + if (registration.ZaakUrl is null) + throw new InvalidOperationException( + $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved."); + + await acl.ApproveZaakAsync(registration.ZaakUrl, ct); + registration.Approve(); + await store.SaveAsync(registration, ct); + } +} diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 800b036..044777d 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -25,6 +25,12 @@ public interface IWorkflowClient public interface IAclClient { Task OpenZaakAsync(string bsn, CancellationToken ct = default); + + /// + /// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting + /// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen. + /// + Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs index 293be9c..c4b3129 100644 --- a/services/domain/Big.Infrastructure/AclHttpClient.cs +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -22,7 +22,18 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli return new Uri(opened.ZaakUrl); } + public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var response = await http.PostAsJsonAsync( + new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct); + response.EnsureSuccessStatusCode(); + } + private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + + private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); } diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index 2818b7d..0fdfb48 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -41,4 +41,27 @@ public class AclHttpClientTests var ex = await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task Approves_a_zaak_by_posting_its_url_to_statussen() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://acl/statussen", capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); + } + + [Fact] + public async Task Approve_throws_when_the_acl_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.BadGateway)); + + await Assert.ThrowsAsync( + () => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + } }