feat(behandel): behandel-portal — werkbak + beoordeling (closes #13) (#87)
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m3s
CI / frontend (push) Successful in 2m30s
CI / mutation (push) Successful in 4m59s
CI / verify-stack (push) Successful in 7m5s

## What & why

Finishes **S-12 · Behandel-portal — werkbak + beoordeling**. The backend sub-slices (S-12a/b/c-1/c-2) were merged, but the slice's stated outcome — a behandel *portal* with medewerker login, a werkbak, and decide — had no frontend. This adds it.

- **`libs/auth`**: `MedewerkerAuthService` + `provideMedewerkerAuth` (Keycloak `medewerker` realm), a `roles`/`hasRole` surface on the shared `AuthService`, and a realm-roles protocol mapper so the SPA can read `behandelaar`/`teamlead` from the token. The BFF remains the security boundary (ADR-0013).
- **`apps/behandel`**: a new Nx Angular app mirroring self-service — medewerker OIDC login and a **werkbak** page listing registrations awaiting beoordeling (`GET /behandel/werkbak`) with per-row **Goedkeuren/Afwijzen** actions (`POST /behandel/registrations/{id}/decide`) that refresh the list. NL DS/Utrecht, standalone + signals.
- **e2e**: the walking-skeleton happy path now approves through the real portal (behandelaar logs in, finds the row by reference, clicks Goedkeuren) instead of the temporary admin endpoint.
- **infra/docs**: behandel service in compose (`:8142`, depends on Keycloak); added to the smoke `WAIT_SVCS` + CI log dump; `frontend-decisions.md` and `demo-script.md` updated.

Closes #13

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #13`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes. *(behandel image + container verified locally; full stack gated in CI.)*
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added — ADR-0013 (merged with the backend sub-slices) already covers the wiring; no new decision here.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- Verified locally: auth + behandel + all frontend projects pass lint & unit tests (incl. axe WCAG 2.1 AA); production build green; the behandel Docker image builds and serves with the correct baked `medewerker` config + SPA fallback.
- The full compose-up smoke, e2e, and mutation are CI-gated (known local full-stack verify limits).
- **Follow-ups (not in scope):** the `WerkbakItem` contract has no citizen name (werkbak shows the BSN) — adding one is a BFF+domain contract change; and the domain's temporary admin `approve` endpoint is now unused by the e2e and could be removed.

Reviewed-on: #87
This commit was merged in pull request #87.
This commit is contained in:
2026-07-16 08:31:57 +00:00
parent d226b6402d
commit 3abf8f7ccf
35 changed files with 918 additions and 24 deletions

View File

@@ -1,10 +1,11 @@
import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
test('DigiD login → submit → public INGEDIENDapprove → public INGESCHREVEN', async ({ page, request }) => {
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
@@ -43,21 +44,42 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible();
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
// path by the worker, so wait for it before approving.
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
await page.goto('http://behandel/');
await page.locator('#username').fill('merel-behandelaar');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
// tasks, so a positional match could act on someone else's registration.
const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect
.poll(async () => {
const res = await request.get(`http://domain:8080/registrations/${reference}`);
return res.ok() ? (await res.json()).zaakUrl : null;
await page.reload();
return goedkeuren.count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeTruthy();
.toBeGreaterThan(0);
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
expect(approve.status()).toBe(204);
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
// the decision never reaches the domain — so the registration would stay INGEDIEND.
const decided = page.waitForResponse(
(r) =>
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
r.request().method() === 'POST',
);
await goedkeuren.click();
expect((await decided).status()).toBe(204);
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
// its reference) as INGESCHREVEN.
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
// its reference) now shows INGESCHREVEN.
await page.goto('http://openbaar/');
await expect
.poll(async () => {
await page.reload();