diff --git a/services/event-subscriber/EventSubscriber.Api/Program.cs b/services/event-subscriber/EventSubscriber.Api/Program.cs index 1b8b981..ee27d70 100644 --- a/services/event-subscriber/EventSubscriber.Api/Program.cs +++ b/services/event-subscriber/EventSubscriber.Api/Program.cs @@ -54,9 +54,9 @@ await app.RunAsync(); /// The NRC notification body, as Open Notificaties POSTs it. Only the fields the /// projection needs are bound; aanmaakdatum/kenmerken are ignored for the minimal slice. -public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl) +public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null) { - public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl); + public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject); } public partial class Program diff --git a/services/event-subscriber/EventSubscriber.Application/Notification.cs b/services/event-subscriber/EventSubscriber.Application/Notification.cs index 9e99b8f..c57884d 100644 --- a/services/event-subscriber/EventSubscriber.Application/Notification.cs +++ b/services/event-subscriber/EventSubscriber.Application/Notification.cs @@ -11,14 +11,23 @@ public sealed record Notification( string Kanaal, string Resource, string Actie, - Uri ResourceUrl) + Uri ResourceUrl, + Uri? HoofdObject = null) { - /// The only event the walking-skeleton projection reacts to: a zaak being created. + /// A zaak being created — projected as INGEDIEND. public bool IsZaakCreated => Kanaal == "zaken" && Resource == "zaak" && Actie == "create"; - /// The zaak UUID — the trailing path segment of the resource URL — used as the projection key. - public string ZaakId => ResourceUrl.Segments[^1].Trim('/'); + /// A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the + /// walking skeleton the only status ever set after creation is the approval, and the subscriber may + /// not read OpenZaak (§8.1), so any status-create is taken as the approval. + 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('/'); /// /// 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 1610787..2779e5c 100644 --- a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs +++ b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs @@ -7,13 +7,15 @@ namespace EventSubscriber.Application; /// public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) { - /// Handle one inbound notification. Ignores anything that is not a zaak-created event. + /// Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a + /// status being set (INGESCHREVEN); ignores everything else. public async Task HandleAsync(Notification notification, CancellationToken ct = default) { - if (!notification.IsZaakCreated) + if (!notification.IsZaakCreated && !notification.IsZaakStatusSet) return; - var recorded = new RecordedNotification(notification.IdempotencyKey, notification.Actie, notification.ZaakId); + var recorded = new RecordedNotification( + notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource); // 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. @@ -31,8 +33,10 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore await store.UpsertAsync(ToEntry(recorded), ct); } - /// The minimal projection row for an accepted notification. A zaak-create maps to - /// INGEDIEND; bsn/naam are deferred (ADR-0008). + /// 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, RegistrationStatus.Ingediend); + => new(recorded.ZaakId, recorded.Resource == "status" + ? RegistrationStatus.Ingeschreven + : RegistrationStatus.Ingediend); } diff --git a/services/event-subscriber/EventSubscriber.Application/Ports.cs b/services/event-subscriber/EventSubscriber.Application/Ports.cs index 911ee68..a2a3cf6 100644 --- a/services/event-subscriber/EventSubscriber.Application/Ports.cs +++ b/services/event-subscriber/EventSubscriber.Application/Ports.cs @@ -19,8 +19,10 @@ public interface INotificationLog Task> AllAsync(CancellationToken ct = default); } -/// A notification that has been accepted, retaining what a rebuild needs to recompute its projection row. -public sealed record RecordedNotification(string Key, string Actie, string ZaakId); +/// 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); /// 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 3f342a3..de1eeb8 100644 --- a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs +++ b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs @@ -17,4 +17,7 @@ public static class RegistrationStatus { /// A zaak has been created; the registration is submitted. public const string Ingediend = "INGEDIEND"; + + /// The registration has been approved and entered in the register (S-09b). + public const string Ingeschreven = "INGESCHREVEN"; } diff --git a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs index 5a25bc1..d3d2a00 100644 --- a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs +++ b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs @@ -15,6 +15,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog Key = notification.Key, Actie = notification.Actie, ZaakId = notification.ZaakId, + Resource = notification.Resource, ReceivedAt = DateTimeOffset.UtcNow, }); @@ -34,6 +35,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)) + .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource)) .ToListAsync(ct); } diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs new file mode 100644 index 0000000..858c7cf --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs @@ -0,0 +1,84 @@ +// +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("20260713145944_AddNotificationResource")] + partial class AddNotificationResource + { + /// + 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("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("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/20260713145944_AddNotificationResource.cs b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.cs new file mode 100644 index 0000000..9f4456f --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + /// + public partial class AddNotificationResource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Pre-existing rows are all zaak-creates (the only notification the projector recorded + // before S-09b), so default the backfill to "zaak" rather than "". + migrationBuilder.AddColumn( + name: "resource", + table: "processed_notifications", + type: "text", + nullable: false, + defaultValue: "zaak"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "resource", + table: "processed_notifications"); + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs index 4bcd24c..39692a2 100644 --- a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs +++ b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs @@ -37,6 +37,11 @@ namespace Projection.ReadModel.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("received_at"); + b.Property("Resource") + .IsRequired() + .HasColumnType("text") + .HasColumnName("resource"); + b.Property("ZaakId") .IsRequired() .HasColumnType("text") diff --git a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs index b260576..b826ccf 100644 --- a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs +++ b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs @@ -35,6 +35,7 @@ public sealed class ProjectionDbContext(DbContextOptions op e.Property(r => r.Key).HasColumnName("key"); 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.ReceivedAt).HasColumnName("received_at"); }); } @@ -55,5 +56,10 @@ public sealed class ProcessedNotificationRow public required string Key { get; set; } public required string Actie { get; set; } public required string ZaakId { get; set; } + + /// The ZGW resource (e.g. zaak or status) — retained so a rebuild reprojects + /// the right status without reading OpenZaak (S-09b). + public required string Resource { get; set; } + public DateTimeOffset ReceivedAt { get; set; } } diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 3059aa7..7cc171e 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -26,12 +26,19 @@ public sealed class InMemoryAclClient : IAclClient public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak"); public string? OpenedForBsn { get; private set; } + public Uri? ApprovedZaakUrl { get; private set; } public Task OpenZaakAsync(string bsn, CancellationToken ct = default) { OpenedForBsn = bsn; return Task.FromResult(OpenedZaakUrl); } + + public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ApprovedZaakUrl = zaakUrl; + return Task.CompletedTask; + } } /// An in-memory registration store for the domain acceptance scenario. diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index a1dde7b..683e0d7 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -11,10 +11,17 @@ public sealed class InMemoryZaakGateway : IZaakGateway public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123"); public ZaakRequest? Captured { get; private set; } + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; } public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { Captured = request; return Task.FromResult(CreatedZaakUrl); } + + public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } }