## What & why S-10a, the **workflow/timeout spine** of the (split) document-upload slice: the registratie process now parks at a **`WachtOpDocumenten`** user task with an **interrupting `P30D` boundary timer**. When the documents arrive the task completes and the process continues into the diploma routing (S-13) → Beoordelen; if the 30 days lapse, the timer cancels the wait, runs a `RegistratieVerlopen` external-worker task, and the domain expires the aggregate to a new terminal status **`Verlopen`**. Backend only — the real upload trigger (portal → BFF → ACL → Documenten API) is S-10b (#103). Closes #102 Mechanism recorded in **ADR-0017**; opened as proposal #104. Mirrors the S-14 escalation (boundary-timer + external-worker) and S-11 withdrawal (interrupting cancel) patterns. ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (red→green pairs per layer). - [x] Implementation makes the test pass. - [x] Conventional Commits referencing the issue (`refs #102`). - [ ] CI green — all Gitea Actions jobs (pending on this PR). - [x] `docker compose up` health unaffected (no new services; deploy path unchanged). - [x] Docs updated (ADR-0017, demo-script, BACKLOG split). - [x] ADR added (`docs/architecture/adr-0017-document-wait-timeout-cancellation.md`). - [x] Demo note in `docs/demo-script.md`. ## Notes for reviewers - **Domain** (`Registration.Expire()` + `Verlopen`), **application** (`ExpireRegistrationWorker`), **infra** (`RegistratieVerlopenProcessor`/`Pump`, `IRegistratieVerlopenClient`, Flowable acquire/complete + `CompleteDocumentWaitAsync`) — the timeout counterpart to the OpenZaak/escalation worker trios; idempotent per §8.6. - **BPMN** verified live against a `flowable-rest` probe: complete `WachtOpDocumenten` → routes to Beoordelen; fire the P30D timer → `RegistratieVerlopen` job (carrying `registrationId`) + the wait task cancelled. `verify-domain` exercises both branches in-stack (completes the wait in every existing block; fires the timer and asserts `Verlopen` in a new block). - **Scope boundary:** on expiry the aggregate goes `Verlopen` and the process ends, but the ZGW *zaak* is not yet set to a cancellation status — that needs a new ACL method + statustype seeding and is folded into S-10b (noted in ADR-0017). - `CompleteDocumentWaitAsync` is built and HTTP-tested here but not yet called from a domain endpoint; S-10b wires the upload trigger to it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
@@ -27,6 +27,11 @@ public interface IDomainClient
|
||||
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Provide the documents the caller's own registration is waiting for ("documenten
|
||||
/// aanleveren"). 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.</summary>
|
||||
Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -63,6 +68,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
$"registrations/{registrationId}/documents", new { bsn }, ct);
|
||||
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return false;
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
|
||||
@@ -104,6 +104,26 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
|
||||
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
|
||||
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
|
||||
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
|
||||
// is S-10b — this is the trigger that unblocks the process.
|
||||
app.MapPost("/self-service/registrations/{id}/documents", async (string id, 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 provided = await domain.ProvideDocumentsAsync(id, bsn, ct);
|
||||
return provided ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -94,6 +94,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
return Task.FromResult(WithdrawSucceeds);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Bsn)? DocumentsProvidedFor { get; private set; }
|
||||
|
||||
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
|
||||
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
|
||||
public bool ProvideDocumentsSucceeds { get; set; } = true;
|
||||
|
||||
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
DocumentsProvidedFor = (registrationId, bsn);
|
||||
return Task.FromResult(ProvideDocumentsSucceeds);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -112,5 +112,46 @@ public class SelfServiceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage ProvideDocuments(string? bearer, string id = "reg-123")
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/documents");
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_providing_documents_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(factory.Domain.DocumentsProvidedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provides_documents_for_the_callers_registration_forwarding_the_id_and_bsn()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"), "reg-9"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal(("reg-9", "123456782"), factory.Domain.DocumentsProvidedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.ProvideDocumentsSucceeds = false;
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/self-service/registrations/{id}/documents": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openbaar/register": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user