Compare commits

...
11 Commits
Author SHA1 Message Date
notandClaude Opus 4.8 8d936ffdaa test(acceptance): CapturingDomainClient implements ProvideDocumentsAsync (refs #102)
CI / unit (pull_request) Successful in 1m10s
CI / lint (pull_request) Successful in 1m22s
CI / build (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 2m42s
CI / mutation (pull_request) Successful in 5m28s
CI / verify-stack (pull_request) Failing after 15m47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:48:58 +02:00
notandClaude Opus 4.8 990db61ba7 docs(backlog): S-10a includes the provision trigger; S-10b is real ZGW storage (refs #102)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:46:45 +02:00
notandClaude Opus 4.8 5402bc179c docs(workflow): S-10a owns the document-provision trigger (ADR-0017, demo) (refs #102)
Records why the provision trigger (domain + BFF + portal 'Documenten aanleveren')
lives in S-10a — the WachtOpDocumenten gate would otherwise leave the e2e red — and
narrows S-10b to the real ZGW document storage. Notes the withdrawal-while-waiting
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:46:13 +02:00
notandClaude Opus 4.8 2b60f8e51f feat(portal): 'Documenten aanleveren' action on the self-service page (refs #102)
After submitting, the zorgprofessional supplies their documents; the page posts to
the BFF keyed by the reference and confirms ("Uw documenten zijn aangeleverd"), with
a surfaced failure + retry. The registration e2e provides documents before the
behandelaar step, since the process now parks at WachtOpDocumenten first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:45:24 +02:00
notandClaude Opus 4.8 07139324a3 feat(domain): timeout worker skips an already-resolved registration (refs #102)
Expire only a still-open (INGEDIEND/IN_BEHANDELING) registration; an already
resolved one (expired, or withdrawn/decided while it waited) is left untouched so
the job completes without violating the aggregate invariant (§8.6). Closes the
S-10a/S-11 race where a withdrawal-while-waiting would loop the expiry job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:45:11 +02:00
notandClaude Opus 4.8 0d1e2825e5 test(domain): timeout worker no-ops on an already-resolved registration (refs #102)
RED: if the citizen withdrew while parked at WachtOpDocumenten, the RegistratieVerlopen
job finds a terminal (INGETROKKEN) aggregate; the worker must no-op and let the job
complete, not throw into a redelivery loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:45:11 +02:00
notandClaude Opus 4.8 cf1c77489d test(portal): self-service offers 'documenten aanleveren' after submit (refs #102)
RED: after submitting, a "Documenten aanleveren" action posts to the BFF keyed by
the reference and the page confirms; a failure surfaces an alert and keeps the
action. Regenerates the api-client from the updated BFF spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:42:36 +02:00
notandClaude Opus 4.8 404454d270 feat(bff): POST /self-service/registrations/{id}/documents (S-10a) (refs #102)
Authenticated self-service endpoint that takes the bsn from the DigiD token,
forwards "documenten aanleveren" to the domain, and relays 404 for an unknown or
not-owned registration. Regenerates the committed openapi.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:41:11 +02:00
notandClaude Opus 4.8 771450d46d test(bff): self-service documents endpoint forwards id + bsn to the domain (refs #102)
RED: POST /self-service/registrations/{id}/documents requires a digid token, takes
the bsn from the token, forwards to the domain, and relays the domain's 404 for an
unknown/not-owned registration. Adds the IDomainClient.ProvideDocumentsAsync port +
client + fake; the endpoint itself follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:39:31 +02:00
notandClaude Opus 4.8 c21becd5b9 feat(domain): ProvideDocuments completes the wait + POST /registrations/{id}/documents (refs #102)
The provide-documents use case completes the WachtOpDocumenten task via the
Workflow Client (owner-scoped by bsn, best-effort), exposed as an owner-scoped
domain endpoint. This is the trigger that unblocks the process; the real file
upload + ZGW storage lands in S-10b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:37:25 +02:00
notandClaude Opus 4.8 7ceb22d46d test(domain): providing documents completes the WachtOpDocumenten wait (refs #102)
RED: the ProvideDocuments use case completes the document wait via the Workflow
Client, owner-scoped by the caller's bsn (a different bsn is NotFound), and is
best-effort when no process was started yet — mirroring WithdrawRegistration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:36:21 +02:00
19 changed files with 444 additions and 20 deletions
+6 -6
View File
@@ -201,17 +201,17 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary: Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
#### S-10a · Document-wait task + 30-day timeout cancellation (workflow spine) — #102 #### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status via an external-worker (mirrors S-14 escalation / S-11 withdrawal). Backend only, no frontend. **Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom. **Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
#### S-10b · Diploma upload via ACL Documenten API + self-service portal — #103 #### S-10b · Real diploma upload stored via the ACL Documenten API — #103
**Outcome:** the self-service portal supports diploma upload; the document is stored in the ZGW Documenten (DRC) API and related to the zaak, with all document calls routed through the ACL (§8.1). A successful upload completes the `WachtOpDocumenten` task from S-10a. Depends on #102. **Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the document is stored in the ZGW Documenten (DRC) API and related to the zaak, with all document calls routed through the ACL (§8.1), and the zaak is set to a cancellation status on timeout expiry. Builds on the S-10a trigger/wait. Depends on #102.
**Acceptance:** BDD upload-completes-wait-task; Playwright e2e upload journey. **Acceptance:** ACL Documenten gateway integration test; Playwright e2e uploads a real document; the openbaar/zaak reflects the stored document.
### S-11 · Withdrawal (Flow 3) ### S-11 · Withdrawal (Flow 3)
@@ -11,6 +11,24 @@
<p utrecht-paragraph role="status"> <p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}. Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p> </p>
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="providingDocuments()"
(click)="provideDocuments()"
>
Documenten aanleveren
</button>
}
@if (withdrawFailed()) { @if (withdrawFailed()) {
<p utrecht-paragraph role="alert"> <p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw. Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
@@ -20,10 +20,12 @@ class FakeAuth extends AuthService {
function providers( function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)), withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
) { ) {
return { return {
post, post,
withdraw, withdraw,
provideDocuments,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ {
@@ -31,6 +33,7 @@ function providers(
useValue: { useValue: {
postSelfServiceRegistrations: post, postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw, postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
}, },
}, },
], ],
@@ -80,6 +83,37 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy(); expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
}); });
it('offers to provide documents after submitting, and doing so confirms', async () => {
const { provideDocuments, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
// The provide-documents call is keyed by the reference the submit returned, and the page confirms.
expect(provideDocuments).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
});
it('surfaces a provide-documents failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => { it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers( const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
@@ -26,6 +26,9 @@ export class RegistrationPage {
protected readonly withdrawing = signal(false); protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false); protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false); protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
@@ -44,6 +47,26 @@ export class RegistrationPage {
}); });
} }
provideDocuments(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
this.bff.postSelfServiceRegistrationsIdDocuments(reference).subscribe({
next: () => {
this.documentsProvided.set(true);
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
}
withdraw(): void { withdraw(): void {
const reference = this.reference(); const reference = this.reference();
if (!reference) { if (!reference) {
@@ -42,9 +42,16 @@ worker expires the correlated aggregate to a new terminal status `Verlopen`.**
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)` - **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
timed out). The *trigger* that calls it (the portal upload) is wired in S-10b; S-10a builds and tests timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
the completion path with the trigger stubbed (the live check completes the task directly to prove the an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
in-time branch, and the domain acceptance drives the worker against an in-memory stand-in). `POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
behind the same action.
## Consequences ## Consequences
@@ -60,12 +67,16 @@ worker expires the correlated aggregate to a new terminal status `Verlopen`.**
**Negative / costs** **Negative / costs**
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other live-check blocks - Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
(S-11/S-12b/S-13/S-14) must complete that task first — a small, explicit step standing in for the documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
S-10b upload until it lands. registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
through the process.
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW - On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps *zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary. S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
## Alternatives considered ## Alternatives considered
+4 -3
View File
@@ -395,9 +395,10 @@ for the citizen's documents (their diploma). Two things can happen:
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status `RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
**VERLOPEN** (the case is cancelled). **VERLOPEN** (the case is cancelled).
The real upload trigger (portal → BFF → domain → ACL → Documenten API) is S-10b; until then the The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
"documents received" step is completing the task in Flowable, and the timeout is demonstrated by **"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
firing the timer early via the management API. turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
demonstrated by firing the 30-day timer early via the management API.
```bash ```bash
DOM=http://localhost:8080 # domain service DOM=http://localhost:8080 # domain service
@@ -226,6 +226,40 @@ export class BffApiV1Service {
); );
} }
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>; getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>; getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>; getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
+16
View File
@@ -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> /// 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); 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> /// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default); Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
@@ -63,6 +68,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return true; 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) public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? []; => await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
+20
View File
@@ -104,6 +104,26 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .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). // 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) => app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{ {
+12
View File
@@ -94,6 +94,18 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(WithdrawSucceeds); 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 (string RegistrationId, string Besluit)? Decided { get; private set; }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
@@ -112,5 +112,46 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 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); private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
} }
+31
View File
@@ -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": { "/openbaar/register": {
"get": { "get": {
"tags": [ "tags": [
+20
View File
@@ -29,6 +29,7 @@ builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>(); builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>(); builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<WithdrawRegistration>(); builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<ProvideDocuments>();
builder.Services.AddScoped<Werkbak>(); builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -107,6 +108,23 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound(); return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
}); });
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn))
return Results.BadRequest(new { error = "A bsn is required to provide documents." });
var outcome = await provide.HandleAsync(new ProvideDocumentsCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open // The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind // Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3). // medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
@@ -134,6 +152,8 @@ public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(string Bsn); public sealed record WithdrawRequest(string Bsn);
public sealed record ProvideDocumentsRequest(string Bsn);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program; public partial class Program;
@@ -12,9 +12,11 @@ namespace Big.Application;
public sealed class ExpireRegistrationWorker(IRegistrationStore store) public sealed class ExpireRegistrationWorker(IRegistrationStore store)
{ {
/// <summary> /// <summary>
/// Process the job. Idempotent: a redelivered job whose registration is already VERLOPEN is a /// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
/// no-op — not persisted again (§8.6, at-least-once delivery). An unknown registration is an error: /// registration is already resolved — a redelivered expiry (VERLOPEN), or one withdrawn/decided
/// it throws, leaving the job un-completed for Flowable to redeliver. /// while it waited (INGETROKKEN/INGESCHREVEN/AFGEWEZEN) — is a no-op, so the job still completes
/// rather than throwing into a redelivery loop. Only a still-open registration is expired. An
/// unknown registration is an error: it throws, leaving the job un-completed for Flowable to redeliver.
/// </summary> /// </summary>
public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default) public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default)
{ {
@@ -24,8 +26,9 @@ public sealed class ExpireRegistrationWorker(IRegistrationStore store)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}."); $"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}.");
// A redelivered job whose registration is already VERLOPEN completes without persisting again. // Only a still-open registration lapses; an already-resolved one (expired, or withdrawn/decided
if (registration.Status == RegistrationStatus.Verlopen) // while it waited) is left untouched so the job can complete without violating the aggregate.
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
return; return;
registration.Expire(); registration.Expire();
@@ -0,0 +1,47 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's signal that they have supplied the documents their registration is
/// waiting for ("documenten aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the
/// DigiD token, forwarded by the BFF): only the registration's own bsn may provide its documents.</summary>
public sealed record ProvideDocumentsCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a provide-documents request.</summary>
public enum ProvideDocumentsOutcome
{
/// <summary>The documents were accepted; the process's document wait was completed (if any).</summary>
Accepted,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary>
/// The provide-documents use case (S-10a): a zorgprofessional supplies the documents their registration
/// is parked waiting for, completing the WachtOpDocumenten task so the registratie process leaves the
/// 30-day wait and continues to beoordeling (ADR-0017). Owner-scoped by bsn. Completing the wait is
/// best-effort: if the registration never started a process (or already left the wait), the request
/// still stands, mirroring how <see cref="WithdrawRegistration"/> cancels best-effort. The actual file
/// upload and its ZGW storage via the ACL is S-10b; this is the trigger that unblocks the process.
/// </summary>
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow)
{
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct);
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return ProvideDocumentsOutcome.NotFound;
// Complete the document wait (if a process is running) so beoordeling can proceed.
if (registration.ProcessInstanceId is not null)
await workflow.CompleteDocumentWaitAsync(registration.ProcessInstanceId, ct);
return ProvideDocumentsOutcome.Accepted;
}
}
@@ -48,6 +48,25 @@ public class ExpireRegistrationWorkerTests
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
} }
[Fact]
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
{
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
// into a redelivery loop.
var store = new FakeRegistrationStore();
var registration = Submitted();
registration.Withdraw();
store.Seed(registration);
await new ExpireRegistrationWorker(store).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
[Fact] [Fact]
public async Task An_unknown_registration_throws_so_the_job_is_redelivered() public async Task An_unknown_registration_throws_so_the_job_is_redelivered()
{ {
@@ -0,0 +1,85 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
// S-10a (#102): the "documents received" use case. A zorgprofessional supplies the documents their
// registration is waiting for; the handler completes the WachtOpDocumenten task via the Workflow Client
// so the process leaves the 30-day wait and continues to beoordeling. Owner-scoped by the caller's bsn,
// like WithdrawRegistration. (The real file upload + ZGW storage is S-10b; this is the trigger path.)
public class ProvideDocumentsTests
{
private const string Bsn = "123456782";
private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId);
return registration;
}
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
[Fact]
public async Task Providing_documents_completes_the_document_wait()
{
var store = new FakeRegistrationStore();
var registration = Submitted("proc-42");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new ProvideDocuments(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task A_different_bsn_cannot_provide_documents()
{
// Owner-scoping: only the registration's own bsn may supply its documents. Another bsn is told
// NotFound (existence not revealed) and the wait is not completed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new ProvideDocuments(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(ProvideDocumentsOutcome.NotFound, outcome);
Assert.Null(workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task Providing_for_an_unknown_registration_is_not_found()
{
var store = new FakeRegistrationStore();
var handler = new ProvideDocuments(store, new FakeWorkflowClient());
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
}
[Fact]
public async Task Providing_before_a_process_started_is_accepted_without_calling_the_workflow()
{
// No process yet → no wait task to complete; the request still stands (best-effort, mirroring
// WithdrawRegistration) and the Workflow Client is not called.
var store = new FakeRegistrationStore();
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new ProvideDocuments(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
Assert.Null(workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task Rejects_a_null_command()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient()).HandleAsync(null!));
}
@@ -72,6 +72,9 @@ public sealed class CapturingDomainClient : IDomainClient
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);
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]); => Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
+6
View File
@@ -25,6 +25,12 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1]; const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
expect(reference, 'the confirmation shows a registration reference').toBeTruthy(); expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
// Provide the documents the registration is waiting for (S-10a): the process parks at
// WachtOpDocumenten right after the zaak is opened, so it only reaches the behandelaar's werkbak once
// the documents are supplied. (S-10b turns this into a real file upload; here it is the trigger.)
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection // The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads // catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
// on open, so reload until *this* submission's row appears. We poll on the reference cell (not a // on open, so reload until *this* submission's row appears. We poll on the reference cell (not a