From 7ef63c7ae96049fa543569092f8a9da3db118ec2 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 14:55:04 +0200 Subject: [PATCH] feat(projection): persist the read projection and expose webhook + read APIs (refs #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .config/dotnet-tools.json | 7 ++ register-referentie.slnx | 5 ++ .../EventSubscriber.Api.csproj | 14 ++++ .../EventSubscriber.Api/Program.cs | 56 +++++++++++++ .../EventSubscriber.Api/appsettings.json | 8 ++ .../DesignTimeProjectionDbContextFactory.cs | 18 +++++ .../Projection.ReadModel/EfNotificationLog.cs | 39 +++++++++ .../Projection.ReadModel/EfProjectionStore.cs | 40 ++++++++++ ...260630125055_InitialProjection.Designer.cs | 79 +++++++++++++++++++ .../20260630125055_InitialProjection.cs | 53 +++++++++++++ .../ProjectionDbContextModelSnapshot.cs | 76 ++++++++++++++++++ .../Projection.ReadModel.csproj | 28 +++++++ .../ProjectionDbContext.cs | 59 ++++++++++++++ .../ServiceCollectionExtensions.cs | 30 +++++++ .../ProjectionApi.Api/Program.cs | 43 ++++++++++ .../ProjectionApi.Api.csproj | 13 +++ .../ProjectionApi.Api/appsettings.json | 8 ++ 17 files changed, 576 insertions(+) create mode 100644 services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj create mode 100644 services/event-subscriber/EventSubscriber.Api/Program.cs create mode 100644 services/event-subscriber/EventSubscriber.Api/appsettings.json create mode 100644 services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs create mode 100644 services/projection-api/Projection.ReadModel/EfNotificationLog.cs create mode 100644 services/projection-api/Projection.ReadModel/EfProjectionStore.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs create mode 100644 services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj create mode 100644 services/projection-api/Projection.ReadModel/ProjectionDbContext.cs create mode 100644 services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs create mode 100644 services/projection-api/ProjectionApi.Api/Program.cs create mode 100644 services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj create mode 100644 services/projection-api/ProjectionApi.Api/appsettings.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4cc6928..f8b500b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -8,6 +8,13 @@ "dotnet-stryker" ], "rollForward": false + }, + "dotnet-ef": { + "version": "10.0.0", + "commands": [ + "dotnet-ef" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/register-referentie.slnx b/register-referentie.slnx index cd8e0c2..3a5661a 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -12,9 +12,14 @@ + + + + + diff --git a/services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj b/services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj new file mode 100644 index 0000000..168c420 --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net10.0 + enable + enable + + + diff --git a/services/event-subscriber/EventSubscriber.Api/Program.cs b/services/event-subscriber/EventSubscriber.Api/Program.cs new file mode 100644 index 0000000..a2f7e55 --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Api/Program.cs @@ -0,0 +1,56 @@ +using EventSubscriber.Application; +using Projection.ReadModel; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("Projection") + ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'"); +// The exact Authorization header value Open Notificaties sends on each abonnement callback. +// NRC probes the callback during registration and refuses it unless it returns 401 without +// this value (ADR-0007), so the webhook enforces it. +var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"] + ?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'"); + +builder.Services.AddProjectionReadModel(connectionString); +builder.Services.AddProjectionWriteSide(); + +var app = builder.Build(); + +// Apply migrations on start so a fresh stack reaches a usable schema unattended. +await app.Services.MigrateProjectionAsync(); + +app.MapGet("/health", () => "Healthy"); + +// The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it. +// Auth-on-callback is mandatory: without the configured Authorization, return 401 (NRC's +// registration probe depends on this — ADR-0007). +app.MapPost("/notifications", async ( + HttpRequest request, + NotificationDto body, + NotificationProjector projector, + CancellationToken ct) => +{ + if (request.Headers.Authorization != webhookToken) + return Results.Unauthorized(); + + await projector.HandleAsync(body.ToNotification(), ct); + return Results.NoContent(); +}); + +// Admin: rebuild the projection from the durable notification log (PRD §8.4 — rebuildable). +app.MapPost("/admin/rebuild", async (NotificationProjector projector, CancellationToken ct) => +{ + await projector.RebuildAsync(ct); + return Results.NoContent(); +}); + +await app.RunAsync(); + +/// The NRC notification body, as Open Notificaties POSTs it. Only the fields the +/// projection needs are bound; aanmaakdatum/kenmerken are ignored for the minimal slice. +public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl) +{ + public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl); +} + +public partial class Program; diff --git a/services/event-subscriber/EventSubscriber.Api/appsettings.json b/services/event-subscriber/EventSubscriber.Api/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/services/event-subscriber/EventSubscriber.Api/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs b/services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs new file mode 100644 index 0000000..f64526e --- /dev/null +++ b/services/projection-api/Projection.ReadModel/DesignTimeProjectionDbContextFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Projection.ReadModel; + +/// Lets dotnet ef migrations build the context at design time without a running +/// database or the host's DI. The connection string is a placeholder — migrations only need the +/// provider to emit Postgres-shaped SQL. +public sealed class DesignTimeProjectionDbContextFactory : IDesignTimeDbContextFactory +{ + public ProjectionDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=projection;Username=projection;Password=projection") + .Options; + return new ProjectionDbContext(options); + } +} diff --git a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs new file mode 100644 index 0000000..5a25bc1 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs @@ -0,0 +1,39 @@ +using EventSubscriber.Application; +using Microsoft.EntityFrameworkCore; + +namespace Projection.ReadModel; + +/// EF Core implementation of the notification log. Idempotency is enforced atomically +/// by the primary key on key: a duplicate insert raises a unique violation, which is +/// caught and reported as "already recorded" rather than failing the request. +public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog +{ + public async Task TryRecordAsync(RecordedNotification notification, CancellationToken ct = default) + { + db.ProcessedNotifications.Add(new ProcessedNotificationRow + { + Key = notification.Key, + Actie = notification.Actie, + ZaakId = notification.ZaakId, + ReceivedAt = DateTimeOffset.UtcNow, + }); + + try + { + await db.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateException) + { + // Already recorded by an earlier (or concurrent) delivery — drop this duplicate. + db.ChangeTracker.Clear(); + return false; + } + } + + public async Task> AllAsync(CancellationToken ct = default) + => await db.ProcessedNotifications + .OrderBy(r => r.ReceivedAt) + .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId)) + .ToListAsync(ct); +} diff --git a/services/projection-api/Projection.ReadModel/EfProjectionStore.cs b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs new file mode 100644 index 0000000..e1d5896 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/EfProjectionStore.cs @@ -0,0 +1,40 @@ +using EventSubscriber.Application; +using Microsoft.EntityFrameworkCore; + +namespace Projection.ReadModel; + +/// EF Core implementation of the projection store over . +public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore +{ + public async Task UpsertAsync(RegisterEntry entry, CancellationToken ct = default) + { + var row = await db.RegisterEntries.FindAsync([entry.Id], ct); + if (row is null) + { + db.RegisterEntries.Add(new RegisterEntryRow + { + Id = entry.Id, + Status = entry.Status, + Bsn = entry.Bsn, + NaamPlaceholder = entry.NaamPlaceholder, + }); + } + else + { + row.Status = entry.Status; + row.Bsn = entry.Bsn; + row.NaamPlaceholder = entry.NaamPlaceholder; + } + + await db.SaveChangesAsync(ct); + } + + public async Task ClearAsync(CancellationToken ct = default) + => await db.RegisterEntries.ExecuteDeleteAsync(ct); + + public async Task> AllAsync(CancellationToken ct = default) + => await db.RegisterEntries + .OrderBy(r => r.Id) + .Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .ToListAsync(ct); +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs new file mode 100644 index 0000000..e0c9dc6 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.Designer.cs @@ -0,0 +1,79 @@ +// +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("20260630125055_InitialProjection")] + partial class InitialProjection + { + /// + 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("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Actie") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actie"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("ZaakId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zaak_id"); + + b.HasKey("Key"); + + b.ToTable("processed_notifications", (string)null); + }); + + modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Bsn") + .HasColumnType("text") + .HasColumnName("bsn"); + + b.Property("NaamPlaceholder") + .HasColumnType("text") + .HasColumnName("naam_placeholder"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("register_projection", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.cs b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.cs new file mode 100644 index 0000000..de5cbba --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260630125055_InitialProjection.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + /// + public partial class InitialProjection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "processed_notifications", + columns: table => new + { + key = table.Column(type: "text", nullable: false), + actie = table.Column(type: "text", nullable: false), + zaak_id = table.Column(type: "text", nullable: false), + received_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_processed_notifications", x => x.key); + }); + + migrationBuilder.CreateTable( + name: "register_projection", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + status = table.Column(type: "text", nullable: false), + bsn = table.Column(type: "text", nullable: true), + naam_placeholder = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_register_projection", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "processed_notifications"); + + migrationBuilder.DropTable( + name: "register_projection"); + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs new file mode 100644 index 0000000..4bcd24c --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs @@ -0,0 +1,76 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Projection.ReadModel; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + [DbContext(typeof(ProjectionDbContext))] + partial class ProjectionDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Actie") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actie"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("ZaakId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zaak_id"); + + b.HasKey("Key"); + + b.ToTable("processed_notifications", (string)null); + }); + + modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Bsn") + .HasColumnType("text") + .HasColumnName("bsn"); + + b.Property("NaamPlaceholder") + .HasColumnType("text") + .HasColumnName("naam_placeholder"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("register_projection", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj b/services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj new file mode 100644 index 0000000..8192c41 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + + direct + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs new file mode 100644 index 0000000..b260576 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; + +namespace Projection.ReadModel; + +/// +/// 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). +/// +public sealed class ProjectionDbContext(DbContextOptions options) : DbContext(options) +{ + /// The public-facing register rows (public-safe filtering is tightened in S-09). + public DbSet RegisterEntries => Set(); + + /// The Event Subscriber's durable log of accepted notifications — idempotency guard and rebuild source. + public DbSet ProcessedNotifications => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(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"); + }); + } +} + +/// A register projection row. Bsn/NaamPlaceholder are deferred (ADR-0008). +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; } +} + +/// An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1). +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; } +} diff --git a/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs b/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1779f21 --- /dev/null +++ b/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using EventSubscriber.Application; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Projection.ReadModel; + +public static class ServiceCollectionExtensions +{ + /// Register the projection against Postgres. + public static IServiceCollection AddProjectionReadModel(this IServiceCollection services, string connectionString) + => services.AddDbContext(o => o.UseNpgsql(connectionString)); + + /// Register the write-side ports (projector store + notification log) used by the Event Subscriber. + public static IServiceCollection AddProjectionWriteSide(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } + + /// Apply any pending EF migrations. Called once on service start so a fresh stack + /// reaches a usable schema without a manual migration step (DoD: compose up reaches green). + public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(ct); + } +} diff --git a/services/projection-api/ProjectionApi.Api/Program.cs b/services/projection-api/ProjectionApi.Api/Program.cs new file mode 100644 index 0000000..74a24be --- /dev/null +++ b/services/projection-api/ProjectionApi.Api/Program.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Projection.ReadModel; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("Projection") + ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'"); + +builder.Services.AddProjectionReadModel(connectionString); + +var app = builder.Build(); + +// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the +// migrations-history lock, so it is safe that the Event Subscriber migrates too. +await app.Services.MigrateProjectionAsync(); + +app.MapGet("/health", () => "Healthy"); + +// The read side of the projection. Public-safe field filtering is tightened in S-09; for now +// the minimal projection only carries id + status (bsn/naam deferred — ADR-0008). +app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) => +{ + var rows = await db.RegisterEntries + .OrderBy(r => r.Id) + .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .ToListAsync(ct); + return Results.Ok(rows); +}); + +app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, CancellationToken ct) => +{ + var row = await db.RegisterEntries + .Where(r => r.Id == id) + .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) + .SingleOrDefaultAsync(ct); + return row is null ? Results.NotFound() : Results.Ok(row); +}); + +await app.RunAsync(); + +public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder); + +public partial class Program; diff --git a/services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj b/services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj new file mode 100644 index 0000000..b91c803 --- /dev/null +++ b/services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/services/projection-api/ProjectionApi.Api/appsettings.json b/services/projection-api/ProjectionApi.Api/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/services/projection-api/ProjectionApi.Api/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +}