diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 31385d9..d601366 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -24,8 +24,18 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); }); +// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak +// URL; the ACL owns the ZGW statustype resolution (§8.1). +app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) => +{ + await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct); + return Results.NoContent(); +}); + app.Run(); public sealed record OpenZaakRequest(string Bsn); +public sealed record SetStatusRequest(string ZaakUrl); + public partial class Program; diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index e285ee7..f9b866a 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -17,4 +17,15 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc return gateway.OpenZaakAsync(request, ct); } + + /// + /// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The + /// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). + /// + public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + } } diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 1962488..c608c7a 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -5,4 +5,11 @@ namespace Acl.Application; public interface IZaakGateway { Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default); + + /// + /// Set the given zaak to the eindstatus (final statustype) of the supplied zaaktype — + /// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from + /// the catalogus and POSTs a status against the zaak, dated . + /// + Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); } diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index f9cce31..10a3c3f 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -41,6 +41,56 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return new Uri(created.Url); } + public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + ArgumentNullException.ThrowIfNull(zaaktypeUrl); + + var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct); + + using var message = new HttpRequestMessage( + HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen")) + { + Content = JsonContent.Create(new StatusDto( + zaakUrl.ToString(), + eindstatus.ToString(), + // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC). + datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))), + }; + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + // The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the + // body so uwsgi gets a Content-Length instead of a chunked body. + await message.Content.LoadIntoBufferAsync(ct); + + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + } + + /// Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus. + private async Task ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct) + { + var query = new Uri(options.BaseUrl, + "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + + var page = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response"); + var results = page.Results ?? []; + + // OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the + // highest volgnummer if the flag is absent. + var eindstatus = results.FirstOrDefault(s => s.IsEindstatus) + ?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault() + ?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}"); + return new Uri(eindstatus.Url); + } + private sealed record ZaakDto( [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("zaaktype")] string Zaaktype, @@ -50,4 +100,17 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : private sealed record ZaakCreatedDto( [property: JsonPropertyName("url")] string Url); + + private sealed record StatusDto( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("statustype")] string Statustype, + [property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet); + + private sealed record StatustypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record StatustypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("volgnummer")] int Volgnummer, + [property: JsonPropertyName("isEindstatus")] bool IsEindstatus); } diff --git a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs index bcdfdcc..a743913 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs @@ -65,6 +65,43 @@ public sealed class OpenZaakFixture : IDisposable return JsonDocument.Parse(json).RootElement.Clone(); } + /// GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers. + public async Task GetJsonAsync(Uri url, CancellationToken ct = default) + { + using var message = new HttpRequestMessage(HttpMethod.Get, url); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken()); + + using var response = await Http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(ct); + return JsonDocument.Parse(json).RootElement.Clone(); + } + + /// The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets. + public async Task FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default) + { + var query = new Uri(BaseUrl, + "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); + var page = await GetJsonAsync(query, ct); + var results = page.GetProperty("results"); + + Uri? fallback = null; + var highest = int.MinValue; + foreach (var st in results.EnumerateArray()) + { + if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean()) + return new Uri(st.GetProperty("url").GetString()!); + var volgnummer = st.GetProperty("volgnummer").GetInt32(); + if (volgnummer > highest) + { + highest = volgnummer; + fallback = new Uri(st.GetProperty("url").GetString()!); + } + } + + return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}"); + } + // A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here // rather than reusing Acl.Infrastructure's internal minter to keep that internal. private string MintToken() diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs index 16f2b7e..dfe88b2 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -42,4 +42,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); } + + [Fact] + public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype() + { + var zaaktype = await stack.FindPublishedBigZaaktypeAsync(); + Assert.True(zaaktype is not null, + "No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " + + "seed it with OZ_PUBLISH=1 (`make integration` does this)."); + + var gateway = new OpenZaakGateway(stack.Http, stack.Options); + var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest( + Bronorganisatie: "517439943", + VerantwoordelijkeOrganisatie: "517439943", + Vertrouwelijkheidaanduiding: "openbaar", + Zaaktype: zaaktype!, + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow))); + + await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); + + // The zaak now carries a current status, and it is the zaaktype's eindstatus. + var zaak = await stack.GetZaakAsync(zaakUrl); + var statusUrl = zaak.GetProperty("status").GetString(); + Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status"); + + var status = await stack.GetJsonAsync(new Uri(statusUrl!)); + var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!); + Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString()); + } }