diff --git a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs index 90098c1..73aa6f2 100644 --- a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs +++ b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs @@ -8,7 +8,8 @@ namespace EventSubscriber.Application; public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl) { /// Handle one inbound notification. Reacts to a register record being written to - /// Objecten (S-19b-2, ADR-0030) and ignores everything else. + /// Objecten (S-19b-2, ADR-0030) and ignores everything else. The notification carries only the + /// object URL, so the record is read back through the ACL (§8.1) and becomes the row verbatim. public async Task HandleAsync(Notification notification, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(notification); @@ -16,11 +17,36 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore if (!notification.IsRegisterRecordWritten) return; - // S-19b-2: reading the record back through the ACL and projecting it lands with the - // implementation; today nothing reaches the store. - await Task.CompletedTask; + var record = await acl.GetRegisterRecordAsync(notification.ObjectUrl, ct); + // The object is gone, or holds no register record — nothing to project (§8.6). + if (record is null) + return; + + var recorded = new RecordedNotification( + KeyFor(notification.ObjectUrl, record), record.Id, record.Status, record.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. + if (!await log.TryRecordAsync(recorded, ct)) + return; + + await store.UpsertAsync(ToEntry(recorded), ct); } + /// + /// A deterministic dedup key: the object, plus the state that write puts in the projection. + /// + /// + /// Open Notificaties carries no notification id and may redeliver, so the key is derived from + /// content. It cannot be the object URL alone — the ACL upserts one object per registration, so + /// submit and approval both notify about the *same* URL and the approval would be swallowed as a + /// duplicate. Nor can it include the actie: a retried approval would be a second `update`. Keying + /// on the projected row means a redelivery collapses and a genuine state change does not, which + /// is exactly the property §8.6 asks for. + /// + private static string KeyFor(Uri objectUrl, RegisterRecord record) + => $"objecten:object:{objectUrl}:{record.Status}:{record.Reference}"; + /// Rebuild the projection from the durable notification log (PRD §8.4). public async Task RebuildAsync(CancellationToken ct = default) {