feat(behandelportal): WP-64 werkvoorraad (queue) screen
CI / changes (pull_request) Successful in 15s
CI / lint (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m14s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m10s
CI / e2e (pull_request) Successful in 3m3s
CI / api-client-drift (pull_request) Successful in 2m1s

New GET /werkvoorraad endpoint lists aanvragen still open (Ingediend/InBehandeling),
gated by the medewerker capability (CanBeoordelen) rather than the admin role — reuses
the existing ApplicationSummaryDto, no new DTO. GET /me now surfaces aanvraag:beoordelen
for a behandelaar so the FE can gate with the same AccessStore/capabilityGuard idiom
every other page uses.

FE: a behandeling domain type deliberately narrower than ssp's full AanvraagStatus
union (only the two open tags — illegal states unrepresentable), composed into a
werkvoorraad-list organism from existing shared/ui molecules. Replaces WP-61's
scaffold placeholder as the app's real landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 22:02:35 +02:00
co-authored by Claude Sonnet 5
parent e7156c5132
commit fe69caee63
22 changed files with 958 additions and 198 deletions
+31 -1
View File
@@ -409,6 +409,17 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. ---
// 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, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList())))
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// 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
@@ -462,7 +473,15 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
// tied to a specific brief's live status — see BriefDecisionsDto for that).
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like
// the rest of RoleCapabilities — appended here rather than folded into that switch, since it
// depends on CallerIdentity (medewerker rollen), not the dev X-Role stand-in.
api.MapGet("/me", (HttpContext ctx) =>
{
var caps = Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx)).ToList();
if (Authz.CanBeoordelen(ctx.Caller())) caps.Add("aanvraag:beoordelen");
return new MeDto(caps);
})
.Produces<MeDto>();
// Feature flags (WP-47). GET is readable by any principal (it drives FE gating); the toggle is
@@ -689,6 +708,17 @@ 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)
{
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.",
statusCode: StatusCodes.Status403Forbidden);
}
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47).
IResult FlagsAdmin(HttpContext ctx, Func<IResult> action)
{
+32
View File
@@ -766,6 +766,38 @@
}
}
},
"/api/v1/werkvoorraad": {
"get": {
"tags": [
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApplicationSummaryDto"
}
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/admin/cases/{id}": {
"delete": {
"tags": [
@@ -0,0 +1,98 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// WP-64: the behandelportal's queue of aanvragen needing treatment, gated by the
/// medewerker capability `CanBeoordelen` (WP-62) — not the admin role.
public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private HttpRequestMessage AsBehandelaar(string path)
{
var req = new HttpRequestMessage(HttpMethod.Get, path);
req.Headers.Add("X-Medewerker", "medewerker-1");
return req;
}
private async Task<ApplicationDetailDto> CreateAndSubmitHerregistratie()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 200 }))
.EnsureSuccessStatusCode();
return a;
}
[Fact]
public async Task Behandelaar_sees_submitted_cases_in_the_queue()
{
var a = await CreateAndSubmitHerregistratie();
try
{
var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad"));
res.EnsureSuccessStatusCode();
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var mine = queue.Single(x => x.Id == a.Id);
Assert.Equal("InBehandeling", mine.Status.Tag);
Assert.False(string.IsNullOrEmpty(mine.Owner)); // cross-owner, like /admin/cases
}
finally
{
await _client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}")
{
Headers = { { "X-Role", "admin" } },
});
}
}
[Fact]
public async Task Queue_excludes_concepts()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "herregistratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
try
{
var res = await _client.SendAsync(AsBehandelaar("/api/v1/werkvoorraad"));
res.EnsureSuccessStatusCode();
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
Assert.DoesNotContain(queue, x => x.Id == a.Id);
}
finally
{
await _client.DeleteAsync($"/api/v1/applications/{a.Id}");
}
}
[Fact]
public async Task Zorgverlener_is_forbidden_even_with_admin_role()
{
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/werkvoorraad");
req.Headers.Add("X-Role", "admin"); // admin role, but no X-Medewerker — still a zorgverlener
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
}
[Fact]
public async Task Medewerker_without_behandelaar_rol_is_forbidden()
{
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/werkvoorraad");
req.Headers.Add("X-Medewerker", "medewerker-2");
req.Headers.Add("X-Rollen", "geen");
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
}
[Fact]
public async Task Me_reports_the_capability_only_for_a_behandelaar()
{
var behandelaar = new HttpRequestMessage(HttpMethod.Get, "/api/v1/me");
behandelaar.Headers.Add("X-Medewerker", "medewerker-1");
var caps = (await (await _client.SendAsync(behandelaar)).Content.ReadFromJsonAsync<MeDto>())!;
Assert.Contains("aanvraag:beoordelen", caps.Capabilities);
var zorgverlener = (await (await _client.GetAsync("/api/v1/me")).Content.ReadFromJsonAsync<MeDto>())!;
Assert.DoesNotContain("aanvraag:beoordelen", zorgverlener.Capabilities);
}
}