feat(domain,bff): current-registration lookup for self-service resume (refs #111)

Backend half of S-26. The domain gains IRegistrationStore.FindOpenByBsnAsync (the
citizen's non-terminal INGEDIEND/IN_BEHANDELING registration) + GET
/registrations/current?bsn=; the BFF adds owner-scoped GET /self-service/registrations
(bsn from the DigiD token) returning that registration or 204 when none. Regenerated
services/bff/openapi.json for the new endpoint (the api-client is generated from it).
Store + BFF endpoint unit tests; acceptance/BFF fakes updated for the new members.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
not
2026-07-22 17:04:35 +02:00
co-authored by Claude Opus 4.8
parent 5de8c1e292
commit 24927b1e80
12 changed files with 229 additions and 0 deletions
+20
View File
@@ -5,6 +5,10 @@ namespace Bff.Api;
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>The caller's current open registration, for resuming the self-service portal after a
/// refresh (S-26): the reference (registration id) + its status.</summary>
public sealed record CurrentRegistration(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
@@ -22,6 +26,10 @@ public interface IDomainClient
{
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
@@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status);
}
public async Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct);
// The domain 404s when the citizen has no open registration — that's "none", not an error.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
return new CurrentRegistration(dto.RegistrationId, dto.Status);
}
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
+18
View File
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the
// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token;
// 204 when the citizen has none in flight (so the portal shows the submit form).
app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var current = await domain.GetCurrentRegistrationAsync(bsn, ct);
return current is null ? Results.NoContent() : Results.Ok(current);
})
.RequireAuthorization()
.Produces<CurrentRegistration>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
+12
View File
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result);
}
public string? CurrentQueriedBsn { get; private set; }
/// <summary>The current open registration the fake domain returns (null → the citizen has none in
/// flight, so the BFF replies 204). Tests set this to exercise resume.</summary>
public CurrentRegistration? Current { get; set; }
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
CurrentQueriedBsn = bsn;
return Task.FromResult(Current);
}
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
@@ -168,5 +169,52 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static HttpRequestMessage Current(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_current_registration_lookup_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Current(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Returns_no_content_when_the_caller_has_no_open_registration()
{
using var factory = new BffFactory();
factory.Domain.Current = null;
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
}
[Fact]
public async Task Returns_the_callers_current_registration_when_one_is_open()
{
using var factory = new BffFactory();
factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend");
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
var body = await response.Content.ReadFromJsonAsync<CurrentRegistrationDto>();
Assert.Equal("reg-77", body!.RegistrationId);
Assert.Equal("Ingediend", body.Status);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
}
+41
View File
@@ -28,6 +28,32 @@
"description": "Unauthorized"
}
}
},
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentRegistration"
}
}
}
},
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/self-service/registrations/{id}/withdraw": {
@@ -205,6 +231,21 @@
},
"components": {
"schemas": {
"CurrentRegistration": {
"required": [
"registrationId",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"DecideRequest": {
"required": [
"besluit"