S-09b: Approval flow — temp admin endpoint + status transition to projection #77

Merged
not merged 13 commits from feat/75-approval-flow into main 2026-07-14 09:05:00 +00:00
12 changed files with 174 additions and 15 deletions
Showing only changes of commit 2cacedc469 - Show all commits

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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);
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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")

View File

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

View File

@@ -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>

View File

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