namespace EventSubscriber.Application; /// /// Projects inbound NRC notifications into the read projection. Tolerates duplicate and /// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection /// upsert is idempotent on the register id. Rebuilds the projection by replaying the log. /// 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. 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); if (!notification.IsRegisterRecordWritten) return; 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) { await store.ClearAsync(ct); foreach (var recorded in await log.AllAsync(ct)) await store.UpsertAsync(ToEntry(recorded), ct); } /// The projection row for an accepted notification. The log already holds exactly the /// row's fields, so a rebuild needs no mapping rules and no upstream reads. bsn/naam stay /// deferred — the register record is public-safe by construction (ADR-0027). private static RegisterEntry ToEntry(RecordedNotification recorded) => new(recorded.RegisterId, recorded.Status, recorded.Reference); }