diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index d601366..f220cb1 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -20,7 +20,7 @@ app.MapGet("/health", () => "Healthy"); // The ACL's single operation, exposed as a service endpoint. app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => { - var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct); + var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct); return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); }); @@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella return Results.NoContent(); }); +// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich +// the read projection without reading ZGW itself (§8.1, #78). +app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) => +{ + var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct); + return Results.Ok(new { reference }); +}); + app.Run(); -public sealed record OpenZaakRequest(string Bsn); +public sealed record OpenZaakRequest(string Bsn, string Reference); public sealed record SetStatusRequest(string ZaakUrl); +public sealed record ZaakReferenceRequest(string ZaakUrl); + public partial class Program; diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index f9b866a..a70e7ca 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc defaults.VerantwoordelijkeOrganisatie, defaults.Vertrouwelijkheidaanduiding, defaults.ZaaktypeUrl, - clock.Today); + clock.Today, + registration.Reference); return gateway.OpenZaakAsync(request, ct); } @@ -28,4 +29,12 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); } + + /// The zaak's reference (its ZGW identificatie), for the read projection (#78). + public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + return gateway.GetZaakIdentificatieAsync(zaakUrl, ct); + } } diff --git a/services/acl/Acl.Application/DomainRegistration.cs b/services/acl/Acl.Application/DomainRegistration.cs index de985f0..6773443 100644 --- a/services/acl/Acl.Application/DomainRegistration.cs +++ b/services/acl/Acl.Application/DomainRegistration.cs @@ -1,4 +1,5 @@ namespace Acl.Application; -/// Domain-language payload handed to the ACL. No ZGW concepts here. -public sealed record DomainRegistration(string Bsn); +/// Domain-language payload handed to the ACL. No ZGW concepts here. +/// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78). +public sealed record DomainRegistration(string Bsn, string Reference); diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index c608c7a..583baf5 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -12,4 +12,8 @@ public interface IZaakGateway /// the catalogus and POSTs a status against the zaak, dated . /// Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); + + /// Read the zaak's identificatie — the public-safe reference the register shows. + /// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78). + Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default); } diff --git a/services/acl/Acl.Application/ZaakRequest.cs b/services/acl/Acl.Application/ZaakRequest.cs index 875d162..9e49ac1 100644 --- a/services/acl/Acl.Application/ZaakRequest.cs +++ b/services/acl/Acl.Application/ZaakRequest.cs @@ -6,4 +6,5 @@ public sealed record ZaakRequest( string VerantwoordelijkeOrganisatie, string Vertrouwelijkheidaanduiding, Uri Zaaktype, - DateOnly Startdatum); + DateOnly Startdatum, + string Identificatie); diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 523f215..92d85ea 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : request.Zaaktype.ToString(), request.VerantwoordelijkeOrganisatie, request.Startdatum.ToString("yyyy-MM-dd"), - request.Vertrouwelijkheidaanduiding)), + request.Vertrouwelijkheidaanduiding, + request.Identificatie)), }; message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); @@ -61,6 +62,24 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : "Setting the zaak status", ct); } + public async Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl); + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + // The zaak is a geo resource; the Zaken API requires the CRS header even on GET. + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, "Reading the zaak", ct); + + var zaak = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("OpenZaak returned an empty zaak response"); + return zaak.Identificatie; + } + // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets // a Content-Length instead of a chunked body (as with zaak-create). private async Task PostAsync(string path, object dto, string action, CancellationToken ct) @@ -132,11 +151,15 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : [property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, [property: JsonPropertyName("startdatum")] string Startdatum, - [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); + [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding, + [property: JsonPropertyName("identificatie")] string Identificatie); private sealed record ZaakCreatedDto( [property: JsonPropertyName("url")] string Url); + private sealed record ZaakReadDto( + [property: JsonPropertyName("identificatie")] string Identificatie); + private sealed record StatusDto( [property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("statustype")] string Statustype, diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs index dfe88b2..9cdac3c 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) "seed it with OZ_PUBLISH=1 (`make integration` does this)."); var gateway = new OpenZaakGateway(stack.Http, stack.Options); + var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie var request = new ZaakRequest( Bronorganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943", Vertrouwelijkheidaanduiding: "openbaar", Zaaktype: zaaktype!, - Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)); + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow), + Identificatie: reference); var zaakUrl = await gateway.OpenZaakAsync(request); @@ -36,11 +38,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(), zaakUrl.ToString()); - // ...and that zaak is really persisted with the default-filled fields. + // ...and that zaak is really persisted with the default-filled fields + the reference identificatie. var zaak = await stack.GetZaakAsync(zaakUrl); Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString()); Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); + Assert.Equal(reference, zaak.GetProperty("identificatie").GetString()); } [Fact] @@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) VerantwoordelijkeOrganisatie: "517439943", Vertrouwelijkheidaanduiding: "openbaar", Zaaktype: zaaktype!, - Startdatum: DateOnly.FromDateTime(DateTime.UtcNow))); + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow), + Identificatie: Guid.NewGuid().ToString())); await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index a51a132..2e74c8f 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -22,6 +22,14 @@ public class AclServiceTests Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); return Task.CompletedTask; } + + public Uri? ReadReferenceFor; + + public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) + { + ReadReferenceFor = zaakUrl; + return Task.FromResult("REG-FROM-ZAAK"); + } } private static AclDefaults Defaults() => new() @@ -50,7 +58,7 @@ public class AclServiceTests }; var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); - var url = await service.OpenZaakAsync(new DomainRegistration("123456782")); + var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77")); Assert.Equal(gateway.Result, url); var req = Assert.IsType(gateway.Captured); @@ -59,6 +67,8 @@ public class AclServiceTests Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); + // The registration reference becomes the zaak identificatie (#78). + Assert.Equal("reg-77", req.Identificatie); } [Fact] @@ -103,4 +113,27 @@ public class AclServiceTests await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); Assert.Null(gateway.Approved); } + + [Fact] + public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie() + { + var gateway = new FakeGateway(); + var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + var reference = await service.GetZaakReferenceAsync(zaak); + + Assert.Equal("REG-FROM-ZAAK", reference); + Assert.Equal(zaak, gateway.ReadReferenceFor); + } + + [Fact] + public async Task Reading_a_null_zaak_reference_is_rejected() + { + var gateway = new FakeGateway(); + var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + + await Assert.ThrowsAsync(() => service.GetZaakReferenceAsync(null!)); + Assert.Null(gateway.ReadReferenceFor); + } } diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index 7ca0271..af568c8 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -22,7 +22,7 @@ public class OpenZaakGatewayTests private static ZaakRequest SampleRequest() => new( "517439943", "517439943", "openbaar", - new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4)); + new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1"); private static StubHandler Created(out RequestCapture capture) { @@ -67,6 +67,8 @@ public class OpenZaakGatewayTests Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body); Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body); + // The registration reference is set as the zaak identificatie (#78). + Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body); } [Fact] @@ -357,6 +359,61 @@ public class OpenZaakGatewayTests Assert.Equal(4, rec.Requests.Count); } + [Fact] + public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs() + { + RequestCapture? capture = null; + var handler = new StubHandler(req => + { + capture = new RequestCapture { Seen = req }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }), + }); + }); + + var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)); + + Assert.Equal("REG-XYZ", reference); + Assert.Equal(HttpMethod.Get, capture!.Seen!.Method); + Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString()); + Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme); + Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs"))); + } + + [Fact] + public async Task Reading_a_zaak_throws_when_openzaak_rejects_it() + { + var handler = new StubHandler(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") })); + + var ex = await Assert.ThrowsAsync( + () => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl))); + Assert.Contains("Reading the zaak", ex.Message); + } + + [Fact] + public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body() + { + var handler = new StubHandler(_ => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"), + })); + + var ex = await Assert.ThrowsAsync( + () => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl))); + Assert.Contains("empty zaak response", ex.Message); + } + + [Fact] + public async Task Reading_a_null_zaak_is_rejected() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent")); + + await Assert.ThrowsAsync(() => Gateway(handler).GetZaakIdentificatieAsync(null!)); + } + [Fact] public async Task Approving_rejects_a_null_zaak_or_zaaktype() { diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index 8c191bc..04780e1 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -18,7 +18,7 @@ public sealed class EenZaakOpenenSteps [Given("a domain registration for BSN \"(.*)\"")] public void GivenADomainRegistrationForBsn(string bsn) - => _registration = new DomainRegistration(bsn); + => _registration = new DomainRegistration(bsn, "ACC-REF-1"); [Given("the ACL is configured with these defaults:")] public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults) diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index 683e0d7..09c6414 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -24,4 +24,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); return Task.CompletedTask; } + + public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) + => Task.FromResult("ACC-REF-1"); }