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:
@@ -5,6 +5,10 @@ namespace Bff.Api;
|
|||||||
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
/// <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);
|
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
|
/// <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).
|
/// 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>
|
/// <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);
|
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
|
/// <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
|
/// <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>
|
/// 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);
|
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)
|
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var response = await http.PostAsJsonAsync(
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.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.
|
// 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;
|
// 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).
|
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
|||||||
return Task.FromResult(Result);
|
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; }
|
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
|
/// <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;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using Bff.Api;
|
||||||
|
|
||||||
namespace Bff.Tests;
|
namespace Bff.Tests;
|
||||||
|
|
||||||
@@ -168,5 +169,52 @@ public class SelfServiceEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
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 SubmitAcceptedDto(string RegistrationId, string Status);
|
||||||
|
|
||||||
|
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
"description": "Unauthorized"
|
"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": {
|
"/self-service/registrations/{id}/withdraw": {
|
||||||
@@ -205,6 +231,21 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"CurrentRegistration": {
|
||||||
|
"required": [
|
||||||
|
"registrationId",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registrationId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DecideRequest": {
|
"DecideRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"besluit"
|
"besluit"
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
|||||||
Results.Ok(await werkbak.GetAsync(ct)));
|
Results.Ok(await werkbak.GetAsync(ct)));
|
||||||
|
|
||||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||||
|
// The citizen's current open registration, looked up by bsn — lets the self-service portal resume
|
||||||
|
// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its
|
||||||
|
// callers (§8.3). 404 when the citizen has none in flight.
|
||||||
|
app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("A bsn is required.");
|
||||||
|
|
||||||
|
var registration = await store.FindOpenByBsnAsync(bsn, ct);
|
||||||
|
return registration is null
|
||||||
|
? Results.NotFound()
|
||||||
|
: Results.Ok(new RegistrationResponse(
|
||||||
|
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
|
||||||
|
});
|
||||||
|
|
||||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(id, out var guid))
|
if (!Guid.TryParse(id, out var guid))
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ public interface IRegistrationStore
|
|||||||
|
|
||||||
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
||||||
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
|
||||||
|
/// registration, or <c>null</c> if they have none in flight. Lets the self-service portal resume
|
||||||
|
/// an existing registration after a refresh (S-26); terminal registrations are not resumed.</summary>
|
||||||
|
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
|||||||
|
|
||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||||
|
|
||||||
|
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||||
|
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
|
|||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||||
|
|
||||||
|
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||||
|
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||||
|
|
||||||
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests
|
|||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finds_the_open_registration_for_a_bsn()
|
||||||
|
{
|
||||||
|
var store = new InMemoryRegistrationStore();
|
||||||
|
var open = Registration.Submit("123456782");
|
||||||
|
await store.SaveAsync(open);
|
||||||
|
|
||||||
|
var found = await store.FindOpenByBsnAsync("123456782");
|
||||||
|
|
||||||
|
Assert.NotNull(found);
|
||||||
|
Assert.Equal(open.Id, found.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_in_behandeling_registration_is_still_open()
|
||||||
|
{
|
||||||
|
var store = new InMemoryRegistrationStore();
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
registration.TakeIntoBehandeling();
|
||||||
|
await store.SaveAsync(registration);
|
||||||
|
|
||||||
|
Assert.NotNull(await store.FindOpenByBsnAsync("123456782"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(nameof(Registration.Withdraw))]
|
||||||
|
[InlineData(nameof(Registration.Approve))]
|
||||||
|
[InlineData(nameof(Registration.Reject))]
|
||||||
|
[InlineData(nameof(Registration.Expire))]
|
||||||
|
public async Task A_terminal_registration_is_not_returned_as_open(string transition)
|
||||||
|
{
|
||||||
|
var store = new InMemoryRegistrationStore();
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); // Approve requires an opened zaak
|
||||||
|
switch (transition)
|
||||||
|
{
|
||||||
|
case nameof(Registration.Withdraw): registration.Withdraw(); break;
|
||||||
|
case nameof(Registration.Approve): registration.Approve(); break;
|
||||||
|
case nameof(Registration.Reject): registration.Reject(); break;
|
||||||
|
case nameof(Registration.Expire): registration.Expire(); break;
|
||||||
|
}
|
||||||
|
await store.SaveAsync(registration);
|
||||||
|
|
||||||
|
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Does_not_return_another_bsns_registration_or_an_unknown_bsn()
|
||||||
|
{
|
||||||
|
var store = new InMemoryRegistrationStore();
|
||||||
|
await store.SaveAsync(Registration.Submit("111111110"));
|
||||||
|
|
||||||
|
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<CurrentRegistration?>(null);
|
||||||
|
|
||||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
|
|||||||
@@ -217,4 +217,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
|||||||
|
|
||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||||
|
|
||||||
|
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||||
|
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user