diff --git a/apps/behandel/src/app/werkbak/werkbak-page.spec.ts b/apps/behandel/src/app/werkbak/werkbak-page.spec.ts index 9799845..d704561 100644 --- a/apps/behandel/src/app/werkbak/werkbak-page.spec.ts +++ b/apps/behandel/src/app/werkbak/werkbak-page.spec.ts @@ -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 }); diff --git a/apps/behandel/src/app/werkbak/werkbak-page.ts b/apps/behandel/src/app/werkbak/werkbak-page.ts index cc5b8e4..f73ddb1 100644 --- a/apps/behandel/src/app/werkbak/werkbak-page.ts +++ b/apps/behandel/src/app/werkbak/werkbak-page.ts @@ -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 { - this.loading.set(true); - this.failed.set(false); + /** + * 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); diff --git a/docs/architecture/adr-0032-werkbak-live-refresh.md b/docs/architecture/adr-0032-werkbak-live-refresh.md new file mode 100644 index 0000000..090c37d --- /dev/null +++ b/docs/architecture/adr-0032-werkbak-live-refresh.md @@ -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. diff --git a/docs/demo-script.md b/docs/demo-script.md index 5172b33..7500cc2 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -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 diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index f02ae1a..5d7c8d3 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -57,12 +57,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', @@ -71,26 +91,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