From 566fc9bcea83ab2d4dedd41ad1e48fc991b9a22f Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 14:46:48 +0200 Subject: [PATCH] feat(event-subscriber): enrich the projection with the zaak reference via the ACL (refs #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On each notification the subscriber reads the zaak's reference (identificatie) through the ACL — the only code allowed to talk to ZGW (§8.1) — and persists it on the register_projection row and in the processed_notifications replay log. Storing it in the log keeps rebuild log-only (ADR-0008): no ACL/ZGW access on rebuild. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EventSubscriber.Api/AclHttpClient.cs | 30 ++++++ .../EventSubscriber.Api/Program.cs | 6 ++ .../Notification.cs | 10 +- .../NotificationProjector.cs | 15 +-- .../EventSubscriber.Application/Ports.cs | 17 +++- .../RegisterEntry.cs | 1 + .../AclHttpClientTests.cs | 86 +++++++++++++++++ .../EventSubscriber.Tests.csproj | 1 + .../EventSubscriber.Tests/InMemoryStores.cs | 13 +++ .../NotificationProjectorTests.cs | 20 +++- .../Projection.ReadModel/EfNotificationLog.cs | 3 +- .../Projection.ReadModel/EfProjectionStore.cs | 4 +- ...0714101642_AddReferenceColumns.Designer.cs | 92 +++++++++++++++++++ .../20260714101642_AddReferenceColumns.cs | 38 ++++++++ .../ProjectionDbContextModelSnapshot.cs | 8 ++ .../ProjectionDbContext.cs | 9 ++ .../ProjectionApi.Api/Program.cs | 6 +- .../Steps/RegisterProjectieBijwerkenSteps.cs | 3 +- .../Support/InMemoryProjectionStores.cs | 8 ++ 19 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs create mode 100644 services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs diff --git a/services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs b/services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs new file mode 100644 index 0000000..c24bad2 --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Api/AclHttpClient.cs @@ -0,0 +1,30 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using EventSubscriber.Application; + +namespace EventSubscriber.Api; + +/// +/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference +/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading +/// OpenZaak itself (adr-proposal #78). +/// +public sealed class AclHttpClient(HttpClient http) : IAclClient +{ + public async Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var response = await http.PostAsJsonAsync( + new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("The ACL returned an empty reference response."); + return body.Reference; + } + + private sealed record ReferenceRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + + private sealed record ReferenceResponse([property: JsonPropertyName("reference")] string Reference); +} diff --git a/services/event-subscriber/EventSubscriber.Api/Program.cs b/services/event-subscriber/EventSubscriber.Api/Program.cs index ee27d70..d71b7eb 100644 --- a/services/event-subscriber/EventSubscriber.Api/Program.cs +++ b/services/event-subscriber/EventSubscriber.Api/Program.cs @@ -11,9 +11,15 @@ var connectionString = builder.Configuration.GetConnectionString("Projection") // this value (ADR-0007), so the webhook enforces it. var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"] ?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'"); +// The ACL is the only code that may read ZGW (§8.1); the subscriber enriches the projection with the +// zaak reference through it (adr-proposal #78). +var aclBaseUrl = builder.Configuration["Acl:BaseUrl"] + ?? throw new InvalidOperationException("Missing configuration 'Acl:BaseUrl'"); builder.Services.AddProjectionReadModel(connectionString); builder.Services.AddProjectionWriteSide(); +builder.Services.AddHttpClient( + c => c.BaseAddress = new Uri(aclBaseUrl)); var app = builder.Build(); diff --git a/services/event-subscriber/EventSubscriber.Application/Notification.cs b/services/event-subscriber/EventSubscriber.Application/Notification.cs index c57884d..ac1d8ff 100644 --- a/services/event-subscriber/EventSubscriber.Application/Notification.cs +++ b/services/event-subscriber/EventSubscriber.Application/Notification.cs @@ -24,10 +24,12 @@ public sealed record Notification( public bool IsZaakStatusSet => Kanaal == "zaken" && Resource == "status" && Actie == "create"; - /// The zaak UUID used as the projection key. For a status notification the resource URL is - /// the status, so the zaak comes from hoofdObject (which, for a zaak-create, equals the - /// resource URL) — see the NRC payload note. - public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/'); + /// The zaak URL this notification concerns — hoofdObject (the zaak) for a status + /// notification, else the resource URL (which, for a zaak-create, is the zaak). + public Uri ZaakUrl => HoofdObject ?? ResourceUrl; + + /// The zaak UUID used as the projection key — the trailing segment of . + public string ZaakId => ZaakUrl.Segments[^1].Trim('/'); /// /// A deterministic dedup key. Open Notificaties carries no notification id and may diff --git a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs index 2779e5c..2d08908 100644 --- a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs +++ b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs @@ -5,17 +5,19 @@ namespace EventSubscriber.Application; /// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection /// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log. /// -public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) +public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl) { /// Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a - /// status being set (INGESCHREVEN); ignores everything else. + /// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's + /// reference via the ACL (§8.1) and records it so a rebuild needs no ZGW access (#78). public async Task HandleAsync(Notification notification, CancellationToken ct = default) { if (!notification.IsZaakCreated && !notification.IsZaakStatusSet) return; + var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct); var recorded = new RecordedNotification( - notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource); + notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference); // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped // before it touches the projection, so the projection stays a faithful derived artefact. @@ -36,7 +38,8 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore /// The projection row for an accepted notification: a status-set maps to INGESCHREVEN, /// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008). private static RegisterEntry ToEntry(RecordedNotification recorded) - => new(recorded.ZaakId, recorded.Resource == "status" - ? RegistrationStatus.Ingeschreven - : RegistrationStatus.Ingediend); + => new( + recorded.ZaakId, + recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend, + Reference: recorded.Reference); } diff --git a/services/event-subscriber/EventSubscriber.Application/Ports.cs b/services/event-subscriber/EventSubscriber.Application/Ports.cs index a2a3cf6..7e73309 100644 --- a/services/event-subscriber/EventSubscriber.Application/Ports.cs +++ b/services/event-subscriber/EventSubscriber.Application/Ports.cs @@ -20,9 +20,20 @@ public interface INotificationLog } /// A notification that has been accepted, retaining what a rebuild needs to recompute its -/// projection row — including the ZGW resource, which distinguishes a zaak-create (INGEDIEND) -/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status. -public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource); +/// projection row — the ZGW resource (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and +/// the zaak reference (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78). +public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference); + +/// +/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's +/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW +/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78). +/// +public interface IAclClient +{ + /// The zaak's reference (identificatie) for the read projection. + Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default); +} /// The read projection store. Owned by the projection bounded context (ADR-0008); the /// subscriber writes to it and the projection-api reads it. diff --git a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs index de1eeb8..52eccc5 100644 --- a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs +++ b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs @@ -9,6 +9,7 @@ namespace EventSubscriber.Application; public sealed record RegisterEntry( string Id, string Status, + string? Reference = null, string? Bsn = null, string? NaamPlaceholder = null); diff --git a/services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs b/services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs new file mode 100644 index 0000000..ef170df --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Tests/AclHttpClientTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Text; +using EventSubscriber.Api; + +namespace EventSubscriber.Tests; + +/// +/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through +/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no +/// real ACL is required. +/// +public class AclHttpClientTests +{ + private static AclHttpClient Client(StubHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") }); + + [Fact] + public async Task Reads_a_zaak_reference_by_posting_the_zaak_url_and_returns_it() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-42"}""")); + + var reference = await client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.Equal("REG-42", reference); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); + } + + [Fact] + public async Task Throws_when_the_acl_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.BadGateway)); + + await Assert.ThrowsAsync( + () => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + } + + [Fact] + public async Task Throws_when_the_acl_returns_an_empty_body() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, "null")); + + var ex = await Assert.ThrowsAsync( + () => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Rejects_a_null_zaak_url_without_sending_a_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-1"}""")); + + await Assert.ThrowsAsync(() => client.GetZaakReferenceAsync(null!)); + Assert.Null(capture.Seen); + } +} + +/// A test double for that records the last request and +/// returns a scripted response — mirrors the ACL/domain gateway tests. +internal sealed class StubHandler(Func> onSend) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); +} + +/// Captures the request a client sent, including the (buffered) body. +internal sealed class RequestCapture +{ + public HttpRequestMessage? Seen { get; private set; } + public string? Body { get; private set; } + + public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req => + { + Seen = req; + Body = req.Content is null ? null : await req.Content.ReadAsStringAsync(); + var response = new HttpResponseMessage(status); + if (json is not null) + response.Content = new StringContent(json, Encoding.UTF8, "application/json"); + return response; + }); +} diff --git a/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj b/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj index 6b32f79..7505d9a 100644 --- a/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj +++ b/services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs b/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs index 618e78f..5cbad6e 100644 --- a/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs +++ b/services/event-subscriber/EventSubscriber.Tests/InMemoryStores.cs @@ -5,6 +5,19 @@ namespace EventSubscriber.Tests; /// In-memory stand-ins for the projection store and notification log, so the /// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's /// convention — no mocking library). +/// A fake ACL client that returns a fixed reference derived from the zaak, and records +/// how many times it was called (to prove a rebuild does not re-read via the ACL). +internal sealed class FakeAclClient : IAclClient +{ + public int CallCount { get; private set; } + + public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + { + CallCount++; + return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/')); + } +} + internal sealed class InMemoryNotificationLog : INotificationLog { private readonly Dictionary _byKey = []; diff --git a/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs b/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs index c472c9a..ed2a677 100644 --- a/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs +++ b/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs @@ -12,8 +12,9 @@ public sealed class NotificationProjectorTests private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryProjectionStore _store = new(); + private readonly FakeAclClient _acl = new(); - private NotificationProjector Projector() => new(_log, _store); + private NotificationProjector Projector() => new(_log, _store, _acl); private static Notification ZaakCreated(string url = ZaakUrl) => new("zaken", "zaak", "create", new Uri(url)); @@ -30,6 +31,23 @@ public sealed class NotificationProjectorTests var entry = Assert.Single(await _store.AllAsync()); Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id); Assert.Equal(RegistrationStatus.Ingediend, entry.Status); + // Enriched with the zaak's reference (identificatie), fetched via the ACL (#78). + Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference); + } + + [Fact] + public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl() + { + var projector = Projector(); + await projector.HandleAsync(ZaakCreated()); + var callsAfterProjection = _acl.CallCount; + + await projector.RebuildAsync(); + + var entry = Assert.Single(await _store.AllAsync()); + Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference); + // Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008). + Assert.Equal(callsAfterProjection, _acl.CallCount); } [Fact] diff --git a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs index d3d2a00..b46f510 100644 --- a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs +++ b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs @@ -16,6 +16,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog Actie = notification.Actie, ZaakId = notification.ZaakId, Resource = notification.Resource, + Reference = notification.Reference, ReceivedAt = DateTimeOffset.UtcNow, }); @@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog public async Task> AllAsync(CancellationToken ct = default) => await db.ProcessedNotifications .OrderBy(r => r.ReceivedAt) - .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource)) + .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference)) .ToListAsync(ct); } diff --git a/services/projection-api/Projection.ReadModel/EfProjectionStore.cs b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs index e1d5896..1027fab 100644 --- a/services/projection-api/Projection.ReadModel/EfProjectionStore.cs +++ b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs @@ -15,6 +15,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore { Id = entry.Id, Status = entry.Status, + Reference = entry.Reference, Bsn = entry.Bsn, NaamPlaceholder = entry.NaamPlaceholder, }); @@ -22,6 +23,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore else { row.Status = entry.Status; + row.Reference = entry.Reference; row.Bsn = entry.Bsn; row.NaamPlaceholder = entry.NaamPlaceholder; } @@ -35,6 +37,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore public async Task> AllAsync(CancellationToken ct = default) => await db.RegisterEntries .OrderBy(r => r.Id) - .Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .Select(r => new RegisterEntry(r.Id, r.Status, Reference: r.Reference, Bsn: r.Bsn, NaamPlaceholder: r.NaamPlaceholder)) .ToListAsync(ct); } diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs new file mode 100644 index 0000000..b36ce90 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.Designer.cs @@ -0,0 +1,92 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Projection.ReadModel; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + [DbContext(typeof(ProjectionDbContext))] + [Migration("20260714101642_AddReferenceColumns")] + partial class AddReferenceColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Actie") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actie"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("Resource") + .IsRequired() + .HasColumnType("text") + .HasColumnName("resource"); + + b.Property("ZaakId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zaak_id"); + + b.HasKey("Key"); + + b.ToTable("processed_notifications", (string)null); + }); + + modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Bsn") + .HasColumnType("text") + .HasColumnName("bsn"); + + b.Property("NaamPlaceholder") + .HasColumnType("text") + .HasColumnName("naam_placeholder"); + + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("register_projection", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs new file mode 100644 index 0000000..c76a4b5 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260714101642_AddReferenceColumns.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + /// + public partial class AddReferenceColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "reference", + table: "register_projection", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "reference", + table: "processed_notifications", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "reference", + table: "register_projection"); + + migrationBuilder.DropColumn( + name: "reference", + table: "processed_notifications"); + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs index 39692a2..182add1 100644 --- a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs +++ b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs @@ -37,6 +37,10 @@ namespace Projection.ReadModel.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("received_at"); + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + b.Property("Resource") .IsRequired() .HasColumnType("text") @@ -66,6 +70,10 @@ namespace Projection.ReadModel.Migrations .HasColumnType("text") .HasColumnName("naam_placeholder"); + b.Property("Reference") + .HasColumnType("text") + .HasColumnName("reference"); + b.Property("Status") .IsRequired() .HasColumnType("text") diff --git a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs index b826ccf..d9df709 100644 --- a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs +++ b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs @@ -24,6 +24,7 @@ public sealed class ProjectionDbContext(DbContextOptions op e.HasKey(r => r.Id); e.Property(r => r.Id).HasColumnName("id"); e.Property(r => r.Status).HasColumnName("status").IsRequired(); + e.Property(r => r.Reference).HasColumnName("reference"); e.Property(r => r.Bsn).HasColumnName("bsn"); e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder"); }); @@ -36,6 +37,7 @@ public sealed class ProjectionDbContext(DbContextOptions op e.Property(r => r.Actie).HasColumnName("actie").IsRequired(); e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired(); e.Property(r => r.Resource).HasColumnName("resource").IsRequired(); + e.Property(r => r.Reference).HasColumnName("reference"); e.Property(r => r.ReceivedAt).HasColumnName("received_at"); }); } @@ -46,6 +48,10 @@ public sealed class RegisterEntryRow { public required string Id { get; set; } public required string Status { get; set; } + + /// The citizen-facing reference (the zaak's identificatie) — matches the submit confirmation (#78). + public string? Reference { get; set; } + public string? Bsn { get; set; } public string? NaamPlaceholder { get; set; } } @@ -61,5 +67,8 @@ public sealed class ProcessedNotificationRow /// the right status without reading OpenZaak (S-09b). public required string Resource { get; set; } + /// The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78). + public string? Reference { get; set; } + public DateTimeOffset ReceivedAt { get; set; } } diff --git a/services/projection-api/ProjectionApi.Api/Program.cs b/services/projection-api/ProjectionApi.Api/Program.cs index bc13277..a2b88aa 100644 --- a/services/projection-api/ProjectionApi.Api/Program.cs +++ b/services/projection-api/ProjectionApi.Api/Program.cs @@ -23,7 +23,7 @@ app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) => { var rows = await db.RegisterEntries .OrderBy(r => r.Id) - .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder)) .ToListAsync(ct); return Results.Ok(rows); }); @@ -32,13 +32,13 @@ app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, Cancellat { var row = await db.RegisterEntries .Where(r => r.Id == id) - .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder)) .SingleOrDefaultAsync(ct); return row is null ? Results.NotFound() : Results.Ok(row); }); await app.RunAsync(); -public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder); +public sealed record RegisterEntryDto(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder); public partial class Program; diff --git a/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs b/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs index d65729f..39f485b 100644 --- a/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs +++ b/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs @@ -17,7 +17,8 @@ public sealed class RegisterProjectieBijwerkenSteps private readonly NotificationProjector _projector; private Notification? _notification; - public RegisterProjectieBijwerkenSteps() => _projector = new NotificationProjector(_log, _store); + public RegisterProjectieBijwerkenSteps() + => _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient()); [Given("a zaak is created in OpenZaak with id \"(.*)\"")] public void GivenAZaakIsCreatedInOpenZaakWithId(string id) diff --git a/tests/acceptance/Support/InMemoryProjectionStores.cs b/tests/acceptance/Support/InMemoryProjectionStores.cs index 67a7fd9..ade1106 100644 --- a/tests/acceptance/Support/InMemoryProjectionStores.cs +++ b/tests/acceptance/Support/InMemoryProjectionStores.cs @@ -38,3 +38,11 @@ public sealed class InMemoryProjectionStore : IProjectionStore public IReadOnlyList RowsFor(string id) => [.. _byId.Values.Where(e => e.Id == id)]; } + +/// A fake ACL client for the projection acceptance scenario: returns a reference derived +/// from the zaak, so the projector can enrich rows without a running ACL (#78). +public sealed class InMemoryAclReferenceClient : IAclClient +{ + public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) + => Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/')); +}