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));
}