diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index cf0e448..a2d8151 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -5,6 +5,10 @@ namespace Bff.Api; /// What the self-service submit returns to the portal (the domain's registration id + status). public sealed record SubmitAccepted(string RegistrationId, string Status); +/// The caller's current open registration, for resuming the self-service portal after a +/// refresh (S-26): the reference (registration id) + its status. +public sealed record CurrentRegistration(string RegistrationId, string Status); + /// A projection row as the projection-api serves it. Bsn/NaamPlaceholder are /// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). /// Reference is the public-safe citizen reference (the zaak identificatie, #78). @@ -22,6 +26,10 @@ public interface IDomainClient { Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default); + /// The caller's current open registration (resume after refresh, S-26), or null + /// when they have none in flight. Owner-scoped by . + Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default); + /// Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by /// . Returns false 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. @@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient return new SubmitAccepted(dto.RegistrationId, dto.Status); } + public async Task 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(ct) + ?? throw new InvalidOperationException("The Domain Service returned an empty registration response."); + return new CurrentRegistration(dto.RegistrationId, dto.Status); + } + public async Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync( diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index f2a660a..8f7df13 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -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(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). diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index e1b5132..7985ba1 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient return Task.FromResult(Result); } + public string? CurrentQueriedBsn { get; private set; } + + /// 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. + public CurrentRegistration? Current { get; set; } + + public Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default) + { + CurrentQueriedBsn = bsn; + return Task.FromResult(Current); + } + public (string RegistrationId, string Bsn)? Withdrawn { get; private set; } /// Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned diff --git a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs index 3b23b7b..9b855ba 100644 --- a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs +++ b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs @@ -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(); + 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); } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index 02359e1..a212961 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -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" diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 56d6791..5d0db51 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -141,6 +141,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) => Results.Ok(await werkbak.GetAsync(ct))); // 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) => { if (!Guid.TryParse(id, out var guid)) diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 1a88018..08fdbde 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -102,6 +102,11 @@ public interface IRegistrationStore /// Load a registration by id, or null if none exists. Task GetAsync(RegistrationId id, CancellationToken ct = default); + + /// The citizen's current open (non-terminal: INGEDIEND/IN_BEHANDELING) + /// registration, or null 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. + Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs index 0acfe94..086dc81 100644 --- a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task GetAsync(RegistrationId id, CancellationToken ct = default) => Task.FromResult(_byId.GetValueOrDefault(id)); + + public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(_byId.Values.FirstOrDefault(r => + r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); } diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index de2a029..c9ff30f 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -22,6 +22,10 @@ internal sealed class FakeRegistrationStore : IRegistrationStore public Task GetAsync(RegistrationId id, CancellationToken ct = default) => Task.FromResult(_byId.GetValueOrDefault(id)); + public Task 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; } diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs index 1a63c57..14be96e 100644 --- a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests await Assert.ThrowsAsync(() => 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")); + } } diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs index 75f492f..f6d95b0 100644 --- a/tests/acceptance/Support/BffAcceptanceHost.cs +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); } + public Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(null); + public Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) => Task.FromResult(true); diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 0400d19..72165ed 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -217,4 +217,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task GetAsync(RegistrationId id, CancellationToken ct = default) => Task.FromResult(_byId.GetValueOrDefault(id)); + + public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(_byId.Values.FirstOrDefault(r => + r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); }