S-26/#162 · Werkbak refreshes itself when a registration is ready for beoordeling (#164)
CI / build (push) Successful in 1m8s
CI / lint (push) Successful in 1m23s
CI / unit (push) Successful in 1m27s
CI / frontend (push) Successful in 3m8s
CI / mutation (push) Successful in 6m13s
CI / verify-stack (push) Successful in 10m12s

## What & why

The behandel werkbak now **refreshes itself** while it is open, so a registration that reaches
beoordeling after the behandelaar opened the page shows up on its own — no reload.

`interval(WERKBAK_REFRESH_MS)` (5 s) re-reads the existing BFF endpoint, scoped to the page with
`takeUntilDestroyed()`. A *background* read leaves the rows and states on screen alone until it has
an answer, so a tick never flashes the loading state over rows being read and one failed poll never
swaps the list for the error alert; a read that comes back also clears an earlier failure, so the
view recovers on its own rather than needing the very reload this slice removes.

No new endpoint, dependency or server-side state, and no service boundary moves — rxjs and
`GET /behandel/werkbak` are both already here. **ADR-0032** records why polling rather than a pushed
stream: nothing notifies the BFF either, so SSE/WebSockets would poll the domain *inside* the BFF for
the same freshness, plus connection lifecycle, nginx buffering and a stateful BFF. Proposal: #163.

Closes #162

## 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 #162`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (unchanged; only the behandel bundle differs).
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added in `docs/architecture/` (ADR-0032).
- [x] Demo note in `docs/demo-script.md` (user-visible).

## Notes for reviewers

**The e2e is the real acceptance test, and it took two goes to make it one.** Simply dropping the
`staff.reload()` from the happy path proved nothing: the werkbak was visited *after* the documents
were supplied, so the row was already there at page load. The spec now logs the behandelaar in
**first**, asserts the row is not there yet, and only then has the citizen supply the documents that
route it to Beoordelen — so the row can only reach that already-open, never-reloaded page via the
refresh. Verified both ways against a live stack: with the interval stubbed out it fails at
`Goedkeuren <ref> … element(s) not found` after 30 s; with it, the behandel nginx logs the poll that
delivers the row. The page is foregrounded before the assertion because Chromium throttles timers in
a hidden tab.

**Ceiling (named in the ADR):** a fixed 5 s interval, per open page, that keeps polling in a
background tab; each tick costs one Flowable task query plus a store read per open task. Upgrade
path: publish task events from the domain, then swap the `interval` for a stream — the endpoint
contract and the rendering stay put. Gate on `document.visibilityState` first if request volume is
the concern.

**Two housekeeping notes, neither blocking:**
- #162 is on **no milestone** (DoD item 1). It is portal UX, so it fits neither *Data Governance*
  nor *Production Posture* cleanly — your call where it lands.
- The issue titles itself **S-26**, which already belongs to the self-service resume slice (#111,
  `BACKLOG.md`). Everything here references **#162**; worth renumbering the title if the S-ids are
  meant to stay unique. `BACKLOG.md` is untouched for the same reason (it mirrors the active
  milestone, and this slice is on none).Reviewed-on: #164
This commit was merged in pull request #164.
This commit is contained in:
not
2026-09-04 09:34:14 +00:00
parent d0fb2b3e8c
commit 8b206a005f
5 changed files with 261 additions and 24 deletions
@@ -4,7 +4,7 @@ import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
@@ -81,6 +81,94 @@ describe('WerkbakPage', () => {
});
});
it('picks up a newly submitted registration without a reload', async () => {
// S-26 (#162): a registration reaches Beoordelen asynchronously, after the citizen supplies
// documents — so the werkbak must refresh itself rather than wait for the behandelaar to reload.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(of([sample[0]]))
.mockReturnValue(of(sample));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
expect(screen.getByText('reg-1')).toBeTruthy();
expect(screen.queryByText('reg-2')).toBeNull();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
expect(screen.getByText('reg-2')).toBeTruthy();
// A background refresh must not flash the loading state over the rows the behandelaar is reading.
expect(screen.queryByText(/bezig met laden/i)).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('keeps the rows on screen when a background refresh fails', async () => {
// A blip on a background poll must not replace the list with the load-failure alert; the next
// tick recovers. Only the first load speaks for whether the werkbak is readable at all.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(of(sample))
.mockReturnValue(throwError(() => new Error('503')));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(screen.getByText('reg-1')).toBeTruthy();
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('stops refreshing once the page is destroyed', async () => {
vi.useFakeTimers();
try {
const { getBehandelWerkbak, providers } = setup();
const { fixture } = await render(WerkbakPage, { providers });
fixture.destroy();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS * 3);
expect(getBehandelWerkbak).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('clears a load failure once a refresh succeeds', async () => {
// Without this the werkbak stays stuck on the error until the behandelaar reloads — the very
// thing this slice removes. A recovered read must put the rows back.
vi.useFakeTimers();
try {
const getBehandelWerkbak = vi
.fn()
.mockReturnValueOnce(throwError(() => new Error('503')))
.mockReturnValue(of(sample));
const { providers } = setup({ getBehandelWerkbak });
const { detectChanges } = await render(WerkbakPage, { providers });
expect(screen.getByText(/kon de werkbak niet laden/i)).toBeTruthy();
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
detectChanges();
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
expect(screen.getByText('reg-1')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
+32 -1
View File
@@ -1,7 +1,15 @@
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* How often an open werkbak re-reads itself (S-26/#162, ADR-0032). Exported so the spec advances the
* clock by exactly one interval instead of hard-coding the number.
*/
export const WERKBAK_REFRESH_MS = 5_000;
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
@@ -10,6 +18,11 @@ type Besluit = 'goedkeuren' | 'afwijzen';
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*
* The page also re-reads itself every {@link WERKBAK_REFRESH_MS} while it is open, so a registration
* that reaches beoordeling after the behandelaar opened the werkbak shows up on its own — no reload
* (S-26/#162). Polling rather than a pushed stream: nothing notifies the BFF either, so a stream
* would poll the domain in the BFF instead and add connection state for the same freshness (ADR-0032).
*/
@Component({
selector: 'app-werkbak-page',
@@ -27,19 +40,37 @@ export class WerkbakPage {
constructor() {
this.load();
// ponytail: a fixed interval, polled while the page lives — it keeps refreshing in a background
// tab. Gate on `document.visibilityState` if the request volume ever matters.
interval(WERKBAK_REFRESH_MS)
.pipe(takeUntilDestroyed())
.subscribe(() => this.load({ background: true }));
}
load(): void {
/**
* Read the werkbak. A `background` read is the interval refresh: it leaves the rows and the states
* the behandelaar is looking at alone until it has an answer — no loading flash on every tick, and
* a blip does not swap the list for the failure alert (the next tick recovers). Only a foreground
* read — on open, or after a decision — speaks for whether the werkbak is readable at all.
*/
load(options: { background?: boolean } = {}): void {
const background = options.background ?? false;
if (!background) {
this.loading.set(true);
this.failed.set(false);
}
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
// A read that came back is the answer, so a refresh also clears an earlier failure — the
// werkbak recovers on its own instead of showing the error until someone reloads.
this.failed.set(false);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
if (background) return;
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
@@ -0,0 +1,79 @@
# ADR-0032: The werkbak refreshes itself by polling, not by a pushed stream
- **Status:** Accepted
- **Date:** 2026-09-04
- **Deciders:** Respellion engineering
- **Slice:** #162 (proposal #163). The issue titles it S-26; that id already belongs to
the self-service resume slice (#111), so #162 is the identifier that counts.
## Context
The werkbak (S-12) is a read of the open Flowable `Beoordelen` tasks: portal → BFF
`GET /behandel/werkbak` → domain `Werkbak` query → workflow engine, each task enriched
from its aggregate. A registration reaches `Beoordelen` **asynchronously**, only once the
citizen supplies its documents and the DMN routes it (S-10a) — so it appears in a werkbak
that is already open, and until now a behandelaar had to reload the page to see it.
Three forces shape the mechanism:
- **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish
task events, and there is no bus between the domain and the BFF.
- **The BFF is stateless** and sits behind each portal's nginx.
- **This is the repo's first live-updating view**, so the choice sets a precedent.
## Decision
**The werkbak page re-reads the existing BFF endpoint on a fixed interval
(`WERKBAK_REFRESH_MS`, 5 s) while it is open. No new endpoint, dependency or server-side
state.**
The refresh is a *background* read: it leaves the rows and the loading/failure states
untouched until it has an answer, so a tick never flashes a spinner over rows a
behandelaar is reading and a single failed poll never swaps the list for the error alert.
A read that comes back also clears an earlier failure, so the view recovers on its own —
the same reload this slice set out to remove would otherwise be needed to escape a
transient error. Only a foreground read (on open, after a decision) speaks for whether the
werkbak is readable at all.
### Why not SSE or WebSockets
Neither buys freshness here, because **nothing notifies the BFF either**:
- **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the
domain and forwards diffs — the same latency, plus connection lifecycle, nginx
buffering, and auth on a long-lived connection.
- **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and
sticky-session-bound. A genuine push path would *also* need the domain to publish task
events. Warranted by high-frequency, bidirectional or fan-out-heavy traffic; the werkbak
is none of those.
Polling meets the acceptance ("a registration can be seen in the werkbak once it is ready
for review") in a handful of lines inside one component.
- ponytail ceiling: a fixed 5 s interval, per open page, that keeps polling in a
background tab. Each tick costs one Flowable task query plus a store read per open task.
- Upgrade path: publish task events from the domain, then swap the component's `interval`
for a stream. The endpoint contract and the component's rendering stay as they are;
gate on `document.visibilityState` first if request volume is the concern.
## Consequences
**Positive**
- The outcome is delivered with no new endpoint, dependency, or server-side state, and no
service boundary moves.
- Self-healing: a transient read failure no longer strands the view until a manual reload.
- The e2e got *simpler* — the happy path waits for the werkbak row without reloading the
page, which is itself the live-refresh assertion.
**Negative / costs**
- Staleness is bounded by one interval (≤5 s) rather than instant.
- One `GET /behandel/werkbak` per open werkbak per interval, including in hidden tabs.
- The precedent is polling; a future view with genuinely high-frequency updates will have
to revisit this (see the upgrade path above).
## Coupling rules touched (CLAUDE.md §8)
None. The poll reuses the existing portal → BFF → domain read path: §8.3 (portals talk
only to the BFF) and §8.2 (only the Workflow Client talks to Flowable) are unchanged.
+34
View File
@@ -5,6 +5,40 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-26/#162 — the werkbak refreshes itself (ADR-0032)
**Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open
**appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a
background refresh swaps the rows in without flashing the loading state, and a transient failure no
longer strands the view on its error message until someone reloads.
```bash
# 1. Two windows. Left: the behandel werkbak, already open and idle.
python3 infra/keycloak/check_realms.py otp # a code, valid right now
open http://localhost:8142 # merel-behandelaar / test123 + that code
#
# 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen).
open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF
#
# 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was
# never reloaded and never left the werkbak.
#
# 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the
# absence of the reload IS the assertion.
make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN"
#
# 5. Component level (background refresh, failure recovery, teardown):
pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards)
```
**The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the
page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`.
**Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF
for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path.
---
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
+25 -20
View File
@@ -59,12 +59,32 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible();
// A behandelaar opens the behandel-portal werkbak and approves the registration (goedkeuren) — the
// S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the medewerker
// realm (a different Keycloak realm than the citizen's digid session).
//
// The werkbak is opened BEFORE the citizen supplies the documents that route the registration to
// Beoordelen, so its row cannot be there at page load: the only thing that can deliver it to this
// already-open page is the werkbak refreshing itself (S-26/#162, ADR-0032). This spec used to
// `staff.reload()` in a poll loop here; the absence of that reload is the live-refresh assertion.
await staff.goto('http://behandel/');
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
await loginMedewerker(staff, 'merel-behandelaar');
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// 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 = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect(goedkeuren, 'the registration is not awaiting beoordeling yet').toBeHidden();
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab.
// The process parks at WachtOpDocumenten only after the zaak is opened; the INGEDIEND row above proves
// the zaak exists — so the OpenZaak worker has completed and the process is now at the wait — which is
// why we supply the documents here rather than right after submit, when the trigger would race the
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
// beoordeling.)
await page.bringToFront();
await page.setInputFiles('#diploma', {
name: 'diploma.pdf',
mimeType: 'application/pdf',
@@ -73,26 +93,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
// 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. The staff tab switches to the
// medewerker realm (a different Keycloak realm than the citizen's digid session).
await staff.goto('http://behandel/');
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
await loginMedewerker(staff, 'merel-behandelaar');
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// The registration reaches the Beoordelen user task only after its documents are provided (above), 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 = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect
.poll(async () => {
await staff.reload();
return goedkeuren.count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
// Back to the werkbak — untouched since login, never reloaded. The row arrives on its own once the
// DMN routes the registration to Beoordelen. (Foregrounded so Chromium doesn't throttle the page's
// refresh timer as a hidden tab.)
await staff.bringToFront();
await expect(goedkeuren).toBeVisible({ timeout: 30_000 });
// 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