S-09b: Approval flow — temp admin endpoint + status transition to projection #77
@@ -54,9 +54,9 @@ await app.RunAsync();
|
||||
|
||||
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
|
||||
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
|
||||
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
|
||||
|
||||
@@ -11,14 +11,23 @@ public sealed record Notification(
|
||||
string Kanaal,
|
||||
string Resource,
|
||||
string Actie,
|
||||
Uri ResourceUrl)
|
||||
Uri ResourceUrl,
|
||||
Uri? HoofdObject = null)
|
||||
{
|
||||
/// <summary>The only event the walking-skeleton projection reacts to: a zaak being created.</summary>
|
||||
/// <summary>A zaak being created — projected as INGEDIEND.</summary>
|
||||
public bool IsZaakCreated =>
|
||||
Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
|
||||
|
||||
/// <summary>The zaak UUID — the trailing path segment of the resource URL — used as the projection key.</summary>
|
||||
public string ZaakId => ResourceUrl.Segments[^1].Trim('/');
|
||||
/// <summary>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.</summary>
|
||||
public bool IsZaakStatusSet =>
|
||||
Kanaal == "zaken" && Resource == "status" && Actie == "create";
|
||||
|
||||
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is
|
||||
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the
|
||||
/// resource URL) — see the NRC payload note.</summary>
|
||||
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
|
||||
|
||||
/// <summary>
|
||||
/// A deterministic dedup key. Open Notificaties carries no notification id and may
|
||||
|
||||
@@ -7,13 +7,15 @@ namespace EventSubscriber.Application;
|
||||
/// </summary>
|
||||
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
|
||||
{
|
||||
/// <summary>Handle one inbound notification. Ignores anything that is not a zaak-created event.</summary>
|
||||
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
|
||||
/// status being set (INGESCHREVEN); ignores everything else.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The minimal projection row for an accepted notification. A zaak-create maps to
|
||||
/// INGEDIEND; bsn/naam are deferred (ADR-0008).</summary>
|
||||
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
|
||||
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
|
||||
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
||||
=> new(recorded.ZaakId, RegistrationStatus.Ingediend);
|
||||
=> new(recorded.ZaakId, recorded.Resource == "status"
|
||||
? RegistrationStatus.Ingeschreven
|
||||
: RegistrationStatus.Ingediend);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ public interface INotificationLog
|
||||
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its projection row.</summary>
|
||||
public sealed record RecordedNotification(string Key, string Actie, string ZaakId);
|
||||
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
|
||||
/// projection row — including the ZGW <c>resource</c>, which distinguishes a zaak-create (INGEDIEND)
|
||||
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary>
|
||||
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource);
|
||||
|
||||
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
|
||||
/// subscriber writes to it and the projection-api reads it.</summary>
|
||||
|
||||
@@ -17,4 +17,7 @@ public static class RegistrationStatus
|
||||
{
|
||||
/// <summary>A zaak has been created; the registration is submitted.</summary>
|
||||
public const string Ingediend = "INGEDIEND";
|
||||
|
||||
/// <summary>The registration has been approved and entered in the register (S-09b).</summary>
|
||||
public const string Ingeschreven = "INGESCHREVEN";
|
||||
}
|
||||
|
||||
@@ -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<IReadOnlyList<RecordedNotification>> 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Actie")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("actie");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received_at");
|
||||
|
||||
b.Property<string>("Resource")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("resource");
|
||||
|
||||
b.Property<string>("ZaakId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("zaak_id");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("processed_notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Bsn")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("bsn");
|
||||
|
||||
b.Property<string>("NaamPlaceholder")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("naam_placeholder");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("register_projection", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Projection.ReadModel.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotificationResource : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>(
|
||||
name: "resource",
|
||||
table: "processed_notifications",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "zaak");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "resource",
|
||||
table: "processed_notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ namespace Projection.ReadModel.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received_at");
|
||||
|
||||
b.Property<string>("Resource")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("resource");
|
||||
|
||||
b.Property<string>("ZaakId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> 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; }
|
||||
|
||||
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
|
||||
/// the right status without reading OpenZaak (S-09b).</summary>
|
||||
public required string Resource { get; set; }
|
||||
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -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<Uri> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||
|
||||
@@ -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<Uri> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user