feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s

New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.

Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.

WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-03 09:01:09 +02:00
co-authored by Claude Sonnet 5
parent fe69caee63
commit 4133b30e5d
29 changed files with 1195 additions and 58 deletions
@@ -126,6 +126,20 @@ public sealed record SubmitApplicationRequest(
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
// --- Beoordeling (WP-65): the behandelportal's case-detail screen. ---
public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId, string FileName);
/// Decision flag (ADR-0001): the FE renders "may I decide", it never recomputes the
/// lifecycle. One flag today because all three decision actions share one rule
/// (BeoordelingRules.CanDecide); split into per-action flags if that ever diverges.
public sealed record BeoordelingDecisionsDto(bool CanBesluiten);
public sealed record BeoordelingViewDto(
ApplicationSummaryDto Aanvraag,
IReadOnlyList<BeoordelingDocumentDto> Documenten,
BeoordelingDecisionsDto Decisions);
// --- 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.
@@ -83,6 +83,19 @@ public static class DocumentStore
}
}
/// <summary>Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already-
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
/// keyed by the wizard's own LocalId instead.</summary>
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
{
var set = documentIds.ToHashSet();
lock (_gate)
{
using var db = Db.Create();
return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList();
}
}
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
public static void SetDrcUrl(string documentId, string drcUrl)
{
@@ -0,0 +1,19 @@
using BigRegister.Api.Data;
namespace BigRegister.Domain.Beoordeling;
/// <summary>
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side
/// today (<see cref="CanDecide"/> only, backing the beoordeling detail screen's decision
/// flag) — the decision-recording rules (which besluit is legal, whether it needs a
/// toelichting) land alongside the mutation endpoint in this WP's second half.
/// </summary>
public static class BeoordelingRules
{
/// A behandelaar may record a decision while the aanvraag is in an open, non-terminal
/// status. Concept never reaches here (the endpoint 404s it before calling this); a case
/// already `Goedgekeurd`/`Afgewezen` is final.
public static bool CanDecide(AanvraagStatusTag current) =>
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
or AanvraagStatusTag.MeerInfoGevraagd;
}
+30 -7
View File
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Beoordeling;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Features;
@@ -413,13 +414,34 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`,
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags —
// a behandelaar never needs to see a Concept (not their business yet) or a terminal case.
api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Werkvoorraad(ctx, () =>
api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, "werkvoorraad", () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList())))
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording
// a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method:
// adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n)
// over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here
// same as an unknown id (only /applications/{id}, citizen-scoped, shows a Concept).
api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) =>
Beoordelen(ctx, $"aanvraag/{id}", () =>
{
var c = zaken.ListCases(DateTimeOffset.UtcNow).FirstOrDefault(x => x.Id == id);
if (c is null || c.Status.Tag == "Concept") return Results.NotFound();
var docs = DocumentStore.ByIds(c.DocumentIds)
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
var decisions = new BeoordelingDecisionsDto(
BeoordelingRules.CanDecide(Enum.Parse<AanvraagStatusTag>(c.Status.Tag)));
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
}))
.Produces<BeoordelingViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.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
@@ -708,14 +730,15 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
statusCode: StatusCodes.Status403Forbidden);
}
// One gate for the werkvoorraad read — the enforce twin of `CanBeoordelen` (WP-62/64).
// Unlike the other *Admin gates above, this checks the CallerIdentity directly (medewerker
// rollen), not a role-only Principal — a zorgverlener with X-Role=admin still gets denied.
IResult Werkvoorraad(HttpContext ctx, Func<IResult> action)
// One gate for every behandelaar endpoint (werkvoorraad, WP-64; beoordeling detail, WP-65) —
// the enforce twin of `CanBeoordelen` (WP-62). Unlike the other *Admin gates above, this
// checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a
// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row.
IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
{
if (Authz.CanBeoordelen(ctx.Caller())) return action();
AuditAuthz(ctx, "aanvraag:beoordelen", "werkvoorraad", false, Authz.ResolvePrincipal(ctx));
return Results.Problem(detail: "Alleen een behandelaar mag de werkvoorraad bekijken.",
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx));
return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.",
statusCode: StatusCodes.Status403Forbidden);
}