Add the projection persistence and the two services around it:
- Projection.ReadModel: a shared EF Core (Npgsql) read model owning the projection
schema — register_projection + the subscriber's processed_notifications log — plus
EfProjectionStore / EfNotificationLog (atomic record-or-skip on the PK for idempotency)
and the initial migration. One rebuildable store, written by the subscriber and read
by projection-api (ADR-0008).
- EventSubscriber.Api: POST /notifications NRC callback (enforces the abonnement bearer,
401 without it per ADR-0007), POST /admin/rebuild, /health. Migrates on start.
- ProjectionApi.Api: GET /register, GET /register/{id}, /health — the read side.
dotnet-ef pinned as a local tool for migrations; NuGetAuditMode=direct so EF's
design-time-only tooling transitive doesn't flag the shipped build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
60 lines
2.6 KiB
C#
60 lines
2.6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Projection.ReadModel;
|
|
|
|
/// <summary>
|
|
/// The read projection's persistence. This single store is the system's rebuildable read model
|
|
/// (PRD §8.4): the Event Subscriber writes to it (projector) and the projection-api reads it
|
|
/// (query). Both processes are the one "Read Projection" bounded context and share this schema —
|
|
/// not a cross-domain DB share (ADR-0008 reconciles this with CLAUDE.md §8.5).
|
|
/// </summary>
|
|
public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> options) : DbContext(options)
|
|
{
|
|
/// <summary>The public-facing register rows (public-safe filtering is tightened in S-09).</summary>
|
|
public DbSet<RegisterEntryRow> RegisterEntries => Set<RegisterEntryRow>();
|
|
|
|
/// <summary>The Event Subscriber's durable log of accepted notifications — idempotency guard and rebuild source.</summary>
|
|
public DbSet<ProcessedNotificationRow> ProcessedNotifications => Set<ProcessedNotificationRow>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<RegisterEntryRow>(e =>
|
|
{
|
|
e.ToTable("register_projection");
|
|
e.HasKey(r => r.Id);
|
|
e.Property(r => r.Id).HasColumnName("id");
|
|
e.Property(r => r.Status).HasColumnName("status").IsRequired();
|
|
e.Property(r => r.Bsn).HasColumnName("bsn");
|
|
e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
|
|
});
|
|
|
|
modelBuilder.Entity<ProcessedNotificationRow>(e =>
|
|
{
|
|
e.ToTable("processed_notifications");
|
|
e.HasKey(r => r.Key);
|
|
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.ReceivedAt).HasColumnName("received_at");
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>A register projection row. <c>Bsn</c>/<c>NaamPlaceholder</c> are deferred (ADR-0008).</summary>
|
|
public sealed class RegisterEntryRow
|
|
{
|
|
public required string Id { get; set; }
|
|
public required string Status { get; set; }
|
|
public string? Bsn { get; set; }
|
|
public string? NaamPlaceholder { get; set; }
|
|
}
|
|
|
|
/// <summary>An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1).</summary>
|
|
public sealed class ProcessedNotificationRow
|
|
{
|
|
public required string Key { get; set; }
|
|
public required string Actie { get; set; }
|
|
public required string ZaakId { get; set; }
|
|
public DateTimeOffset ReceivedAt { get; set; }
|
|
}
|