From 4ebc263bdf379ce73b513fa5d4550fe4f7ca5e18 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 15 Jul 2026 12:00:05 +0200 Subject: [PATCH] feat(bff): POST /behandel/registrations/{id}/decide behind the behandelaar policy (refs #13) Forwards a behandelaar's goedkeuren/afwijzen to the domain (which applies the decision and completes the workflow task). Validates the besluit vocabulary (400 on unknown) without troubling the domain. openapi.json + api-client regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/api-client/src/lib/generated/bff-api.ts | 42 ++++++++++++++++ services/bff/Bff.Api/Program.cs | 26 ++++++++++ services/bff/openapi.json | 52 ++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index ca0d967..ca1d1c3 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -24,6 +24,10 @@ import { Observable } from 'rxjs'; +export interface DecideRequest { + besluit: string; +} + export interface OpenbaarEntry { id: string; status: string; @@ -252,4 +256,42 @@ export class BffApiV1Service { ); } + postBehandelRegistrationsIdDecide(id: string, + decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable; + postBehandelRegistrationsIdDecide(id: string, + decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable>; + postBehandelRegistrationsIdDecide(id: string, + decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable>; + postBehandelRegistrationsIdDecide( + id: string, + decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/behandel/registrations/${id}/decide`, + decideRequest,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/behandel/registrations/${id}/decide`, + decideRequest,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/behandel/registrations/${id}/decide`, + decideRequest,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + }; diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 871ae31..1f95d04 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -103,8 +103,28 @@ app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken c .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); +// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which +// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate. +app.MapPost("/behandel/registrations/{id}/decide", + async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) => + { + if (!BehandelAuth.IsKnownBesluit(body.Besluit)) + return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." }); + + await domain.DecideAsync(id, body.Besluit, ct); + return Results.NoContent(); + }) + .RequireAuthorization(BehandelAuth.Policy) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + app.Run(); +/// The behandelaar's decision on a registration. +public sealed record DecideRequest(string Besluit); + // Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013). internal static class BehandelAuth { @@ -112,6 +132,12 @@ internal static class BehandelAuth public const string Policy = "behandelaar"; public const string BehandelaarRole = "behandelaar"; + /// The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a + /// 400 without troubling the domain. Mirrors the domain's BeoordelingsBesluit. + public static bool IsKnownBesluit(string? besluit) => + string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) || + string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase); + /// Lift Keycloak's realm roles (the nested realm_access.roles claim) onto the /// principal as role claims, so RequireRole can authorize on them. public static void AddRealmRoles(ClaimsPrincipal? principal) diff --git a/services/bff/openapi.json b/services/bff/openapi.json index e3591c3..272d9e3 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -88,10 +88,62 @@ } } } + }, + "/behandel/registrations/{id}/decide": { + "post": { + "tags": [ + "Bff.Api" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecideRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + } } }, "components": { "schemas": { + "DecideRequest": { + "required": [ + "besluit" + ], + "type": "object", + "properties": { + "besluit": { + "type": "string" + } + } + }, "OpenbaarEntry": { "required": [ "id",