feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s

Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.

Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-03 09:46:20 +02:00
co-authored by Claude Sonnet 5
parent 4133b30e5d
commit af8a011819
20 changed files with 1303 additions and 20 deletions
@@ -140,6 +140,15 @@ public sealed record BeoordelingViewDto(
IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions);
/// Recording a decision (WP-65b). `Besluit` is the enum member name as a string — same
/// wire convention as `AanvraagStatusDto.Tag` (this backend never ships a raw C# enum,
/// it round-trips names via Enum.Parse/.ToString() at the Contracts boundary, no
/// JsonStringEnumConverter configured). The endpoint 400s an unknown name. Toelichting
/// is required for Afwijzen/MeerInfoOpvragen, validated server-side.
public sealed record RecordBesluitRequest(string Besluit, string? Toelichting = null);
public sealed record RecordBesluitResponse(AanvraagStatusDto Status);
// --- Brief (letter composition) contracts ---
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
@@ -38,17 +38,25 @@ public static class Mappers
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock.
//
// Ingediend/MeerInfoGevraagd (AanvraagStatusTag, WP-63) aren't produced here yet — no
// behandelaar action exists to reach them (WP-65 adds the transition endpoint).
// stays In behandeling until a behandelaar records a decision (WP-65b — before that
// WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure —
// testable by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{
if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null)
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
// behandelaar's explicit besluit is authoritative once made.
if (a.BesluitStatus is { } besluit)
return besluit switch
{
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
};
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
@@ -13,6 +13,14 @@ namespace BigRegister.Api.Data;
/// </summary>
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
/// <summary>
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
/// offers, each advancing <see cref="Aanvraag.BesluitStatus"/> and (via
/// <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/>) the published
/// <see cref="AanvraagStatusTag"/> the FE renders.
/// </summary>
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
/// <summary>
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
@@ -50,6 +58,17 @@ public sealed class Aanvraag
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
public string? ZgwError { get; set; }
/// <summary>WP-65b: a behandelaar's recorded decision, if any. Non-null wins over the
/// auto-approve computation in <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/> —
/// "a recorded decision wins". Mutable across <see cref="AanvraagStatusTag.MeerInfoGevraagd"/>
/// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>).</summary>
public Besluit? BesluitStatus { get; set; }
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
/// the published status's Reden), optional for Goedkeuren.</summary>
public string? BesluitToelichting { get; set; }
}
/// <summary>
@@ -104,6 +123,17 @@ public static class ApplicationStore
}
}
/// Cross-owner single read (WP-65b) — the behandelaar decision endpoint's counterpart of
/// <see cref="Get"/>, same "any owner" shape as <see cref="DeleteAny"/>.
public static Aanvraag? GetAny(string id)
{
lock (_gate)
{
using var db = Db.Create();
return db.Applications.Find(id);
}
}
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
public static IReadOnlyList<Aanvraag> ListAll()
@@ -224,4 +254,24 @@ public static class ApplicationStore
db.SaveChanges();
}
}
/// <summary>Record a behandelaar's decision (WP-65b). The endpoint has already checked
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/> against the
/// freshly-read status before calling this — cross-owner like <see cref="DeleteAny"/>,
/// since a behandelaar decides on any citizen's case. Returns null only if the aanvraag
/// is gone (shouldn't happen — this runs right after the endpoint's own read found it).</summary>
public static Aanvraag? RecordBesluit(string id, Besluit besluit, string? toelichting)
{
lock (_gate)
{
using var db = Db.Create();
var a = db.Applications.Find(id);
if (a is null) return null;
a.BesluitStatus = besluit;
a.BesluitToelichting = toelichting;
a.UpdatedAt = DateTimeOffset.UtcNow;
db.SaveChanges();
return a;
}
}
}
@@ -0,0 +1,288 @@
// <auto-generated />
using System;
using BigRegister.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260803070817_BesluitStatus")]
partial class BesluitStatus
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<int?>("BesluitStatus")
.HasColumnType("INTEGER");
b.Property<string>("BesluitToelichting")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.Property<string>("ZgwError")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.ToTable("FeatureFlags");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class BesluitStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BesluitStatus",
table: "Applications",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BesluitToelichting",
table: "Applications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BesluitStatus",
table: "Applications");
migrationBuilder.DropColumn(
name: "BesluitToelichting",
table: "Applications");
}
}
}
@@ -25,6 +25,12 @@ namespace BigRegister.Api.Data.Migrations
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<int?>("BesluitStatus")
.HasColumnType("INTEGER");
b.Property<string>("BesluitToelichting")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
+34
View File
@@ -442,6 +442,40 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
// --- Besluit (WP-65b): record a behandelaar's decision, advancing the WP-63 status
// lifecycle. Runs against ApplicationStore directly (not the IZaakSource seam) — same
// reasoning as the GET above: a new seam method would force an OpenZaakZaakSource
// write now, which is WP-66's surface, not this one's. The transition-legality check
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
// so the two can never drift.
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx) =>
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
{
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
var now = DateTimeOffset.UtcNow;
var a = ApplicationStore.GetAny(id);
var statusTag = a?.ToStatusDto(now).Tag;
if (a is null || statusTag == "Concept") return Results.NotFound();
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
if (!BeoordelingRules.CanDecide(current))
return Results.Problem(
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
statusCode: StatusCodes.Status409Conflict);
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
}))
.Produces<RecordBesluitResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);
// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is
// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it).
// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly