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;
+ }
}