From 94699f3603c259341fdd513ceaa72dd10f88c337 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 13:06:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(self-service):=20trek=20aanvraag=20in=20?= =?UTF-8?q?=E2=80=94=20withdrawal=20action=20(S-11c-2,=20closes=20#12)=20(?= =?UTF-8?q?#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why Final sub-slice of **S-11 · Withdrawal (Flow 3)** — the user-facing "trek aanvraag in" action, which **closes #12**. - **self-service portal**: the submit confirmation gains a **"Trek aanvraag in"** button. It withdraws the just-submitted registration via `postSelfServiceRegistrationsIdWithdraw(reference)`; success shows an *ingetrokken* confirmation, a failure is surfaced (`role="alert"`) and the action stays available — same confirm-and-surface pattern as submit. - **acceptance**: `Een registratie intrekken` — owner withdraws → INGETROKKEN + workflow cancelled; a different bsn is reported not-found. - **e2e**: `withdrawal.spec.ts` — DigiD submit → trek aanvraag in → the portal confirms ingetrokken. - **docs**: demo-script + frontend-decisions. Together with S-11a (#88), S-11b (#89), S-11c-1 (#90), this completes the flow: citizen withdraws → domain INGETROKKEN → BPMN message event cancels the process → the case leaves the behandelaar's werkbak. Closes #12 ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing tests committed before the implementation. - [x] Implementation makes the tests pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected. - [x] Docs updated (demo-script + frontend-decisions). - [x] ADR — ADR-0014 (from S-11b) covers the cancellation decision; nothing new here. ## Notes for reviewers - Full local gate run before pushing: `dotnet format --verify-no-changes` clean; `make unit` green (Acceptance **11** incl. the 2 new withdrawal scenarios, Big 95, BFF 30, Acl 27, EventSubscriber 19); self-service lint/test/build green (9 tests, incl. the 2 new withdraw tests). - `withdrawal.spec.ts` waits on the *ingetrokken* confirmation (which only renders after the withdraw POST returns), so it can't cancel the request early (the 499 lesson from #87). Live-validated by verify-stack. Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/93 --- .../app/registration/registration-page.html | 26 +++++++- .../registration/registration-page.spec.ts | 44 +++++++++++++- .../src/app/registration/registration-page.ts | 26 +++++++- docs/demo-script.md | 23 ++++++++ docs/frontend-decisions.md | 16 +++++ .../Features/EenRegistratieIntrekken.feature | 22 +++++++ .../Steps/EenRegistratieIntrekkenSteps.cs | 59 +++++++++++++++++++ tests/e2e/withdrawal.spec.ts | 27 +++++++++ 8 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 tests/acceptance/Features/EenRegistratieIntrekken.feature create mode 100644 tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs create mode 100644 tests/e2e/withdrawal.spec.ts diff --git a/apps/self-service/src/app/registration/registration-page.html b/apps/self-service/src/app/registration/registration-page.html index a8b6627..d4739e5 100644 --- a/apps/self-service/src/app/registration/registration-page.html +++ b/apps/self-service/src/app/registration/registration-page.html @@ -3,9 +3,29 @@ Zelfservice — BIG-registratie @if (submitted()) { -

- Uw registratie is ontvangen. Referentie: {{ reference() }}. -

+ @if (withdrawn()) { +

+ Uw registratie met referentie {{ reference() }} is ingetrokken. +

+ } @else { +

+ Uw registratie is ontvangen. Referentie: {{ reference() }}. +

+ @if (withdrawFailed()) { +

+ Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw. +

+ } + + } } @else {

U bent ingelogd met BSN {{ bsn() }}.

@if (failed()) { diff --git a/apps/self-service/src/app/registration/registration-page.spec.ts b/apps/self-service/src/app/registration/registration-page.spec.ts index 284f7da..1119762 100644 --- a/apps/self-service/src/app/registration/registration-page.spec.ts +++ b/apps/self-service/src/app/registration/registration-page.spec.ts @@ -17,12 +17,22 @@ class FakeAuth extends AuthService { } } -function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) { +function providers( + post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), + withdraw = vi.fn().mockReturnValue(of(undefined)), +) { return { post, + withdraw, providers: [ { provide: AuthService, useClass: FakeAuth }, - { provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } }, + { + provide: BffApiV1Service, + useValue: { + postSelfServiceRegistrations: post, + postSelfServiceRegistrationsIdWithdraw: withdraw, + }, + }, ], }; } @@ -56,6 +66,36 @@ describe('RegistrationPage', () => { expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy(); }); + it('offers to withdraw after submitting, and withdrawing confirms', async () => { + const { withdraw, 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: /trek aanvraag in/i })); + + // The withdrawal is keyed by the reference the submit returned, and the page confirms it. + expect(withdraw).toHaveBeenCalledWith('reg-9'); + expect(await screen.findByText(/ingetrokken/i)).toBeTruthy(); + }); + + it('surfaces a withdraw failure and keeps the action available', async () => { + const { providers: p } = providers( + vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), + vi.fn().mockReturnValue(throwError(() => new Error('withdraw 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: /trek aanvraag in/i })); + + expect(await screen.findByRole('alert')).toBeTruthy(); + expect(screen.queryByText(/is ingetrokken/i)).toBeNull(); + expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy(); + }); + it('has no WCAG 2.1 AA violations on the submit page', async () => { // The portal is Dutch; the real index.html sets lang. Set it here so the document-level // html-has-lang rule reflects the app, not the bare jsdom document. diff --git a/apps/self-service/src/app/registration/registration-page.ts b/apps/self-service/src/app/registration/registration-page.ts index da526be..a5380de 100644 --- a/apps/self-service/src/app/registration/registration-page.ts +++ b/apps/self-service/src/app/registration/registration-page.ts @@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui'; /** * The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG * registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and- - * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). + * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After + * submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c). */ @Component({ selector: 'app-registration-page', @@ -22,6 +23,9 @@ export class RegistrationPage { protected readonly reference = signal(undefined); protected readonly submitted = signal(false); protected readonly failed = signal(false); + protected readonly withdrawing = signal(false); + protected readonly withdrawn = signal(false); + protected readonly withdrawFailed = signal(false); submit(): void { this.submitting.set(true); @@ -39,4 +43,24 @@ export class RegistrationPage { }, }); } + + withdraw(): void { + const reference = this.reference(); + if (!reference) { + return; + } + this.withdrawing.set(true); + this.withdrawFailed.set(false); + this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({ + next: () => { + this.withdrawn.set(true); + this.withdrawing.set(false); + }, + // Surface the failure instead of swallowing it: keep the action so the user can retry. + error: () => { + this.withdrawFailed.set(true); + this.withdrawing.set(false); + }, + }); + } } diff --git a/docs/demo-script.md b/docs/demo-script.md index 8a240f3..5ece867 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -275,3 +275,26 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE > The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak → > public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now > drives the behandel portal in place of the old admin endpoint. + +## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014) + +A zorgprofessional can withdraw their own still-open registration from the self-service portal. The +withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner +withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the +behandelaar's werkbak. + +```text +# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123), +# note the "Referentie" on the confirmation. +# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken. +# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer +# appears — its Beoordelen task was cancelled. +``` + +**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped) +→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the +interrupting boundary event ends it → the werkbak drops the case. + +> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path +> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the +> `Een registratie intrekken` acceptance scenarios and the domain live check. diff --git a/docs/frontend-decisions.md b/docs/frontend-decisions.md index e6a7b11..4ceb8e5 100644 --- a/docs/frontend-decisions.md +++ b/docs/frontend-decisions.md @@ -151,3 +151,19 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call). The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy path. + +## Self-service withdrawal: "trek aanvraag in" (S-11c, #12) + +The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the +registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`). + +- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated + `postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The + DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets + the owner withdraw (a mismatch is 404). No extra identity is entered in the UI. +- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page + switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action + stays available to retry — mirroring how submit handles its failure rather than swallowing it. +- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing + after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the + browser round-trip is `tests/e2e/withdrawal.spec.ts`. diff --git a/tests/acceptance/Features/EenRegistratieIntrekken.feature b/tests/acceptance/Features/EenRegistratieIntrekken.feature new file mode 100644 index 0000000..4322007 --- /dev/null +++ b/tests/acceptance/Features/EenRegistratieIntrekken.feature @@ -0,0 +1,22 @@ +# language: en +# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag +# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner +# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store +# and the Workflow Client; the live Flowable message correlation is verified by the domain check. +Feature: Een registratie intrekken + Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken + zodat een aanvraag die ik niet meer wil niet in behandeling blijft. + + Scenario: De zorgprofessional trekt zijn eigen registratie in + Given a submitted registration with a running process + When the zorgprofessional withdraws it + Then the withdrawal succeeds + And the registration has status "INGETROKKEN" + And the running process is cancelled + + Scenario: Een andere zorgprofessional kan de registratie niet intrekken + Given a submitted registration with a running process + When a different zorgprofessional tries to withdraw it + Then the withdrawal is reported not found + And the registration has status "INGEDIEND" + And the running process is not cancelled diff --git a/tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs b/tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs new file mode 100644 index 0000000..bbf3149 --- /dev/null +++ b/tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs @@ -0,0 +1,59 @@ +using Acceptance.Support; +using Big.Application; +using Big.Domain; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for EenRegistratieIntrekken.feature (S-11). Submits a registration (which +/// records its process) and then applies the WithdrawRegistration use case against in-memory ports; +/// one instance per scenario. Scoped to this feature so its "the registration has status" step does +/// not clash with the identically phrased steps in the other features. +[Binding] +[Scope(Feature = "Een registratie intrekken")] +public sealed class EenRegistratieIntrekkenSteps +{ + private const string OwnerBsn = "123456782"; + + private readonly InMemoryRegistrationStore _store = new(); + private readonly InMemoryWorkflowClient _workflow = new(); + private RegistrationId _id; + private WithdrawOutcome _outcome; + + [Given("a submitted registration with a running process")] + public async Task GivenASubmittedRegistrationWithARunningProcess() + => _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn)); + + [When("the zorgprofessional withdraws it")] + public async Task WhenTheZorgprofessionalWithdrawsIt() + => _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync( + new WithdrawRegistrationCommand(_id, OwnerBsn)); + + [When("a different zorgprofessional tries to withdraw it")] + public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt() + => _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync( + new WithdrawRegistrationCommand(_id, "999999990")); + + [Then("the withdrawal succeeds")] + public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome); + + [Then("the withdrawal is reported not found")] + public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome); + + [Then("the registration has status \"(.*)\"")] + public async Task ThenTheRegistrationHasStatus(string expected) + { + var registration = await _store.GetAsync(_id); + Assert.NotNull(registration); + Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant()); + } + + [Then("the running process is cancelled")] + public void ThenTheRunningProcessIsCancelled() + => Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId); + + [Then("the running process is not cancelled")] + public void ThenTheRunningProcessIsNotCancelled() + => Assert.Null(_workflow.WithdrawnProcessInstanceId); +} diff --git a/tests/e2e/withdrawal.spec.ts b/tests/e2e/withdrawal.spec.ts new file mode 100644 index 0000000..7b8336f --- /dev/null +++ b/tests/e2e/withdrawal.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; + +// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws +// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner- +// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page +// then confirms the registration is ingetrokken. +test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => { + // Visiting the guarded page redirects to the Keycloak (mock DigiD) login. + await page.goto('/'); + + await page.locator('#username').fill('jan-burger'); + await page.locator('#password').fill('test123'); + await page.locator('#kc-login').click(); + + await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible(); + + await page.getByRole('button', { name: /indienen/i }).click(); + + // The BFF accepted it and the page shows the confirmation with the reference. + await expect(page.getByText(/ontvangen/i)).toBeVisible(); + + // Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204), + // so awaiting the "ingetrokken" text also proves the request landed — no premature navigation. + await page.getByRole('button', { name: /trek aanvraag in/i }).click(); + + await expect(page.getByText(/is ingetrokken/i)).toBeVisible(); +});