Compare commits

..

2 Commits

Author SHA1 Message Date
d966a198fb feat(domain): withdraw a registration → INGETROKKEN via POST /registrations/{id}/withdraw (refs #12)
All checks were successful
CI / lint (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 57s
CI / unit (pull_request) Successful in 1m10s
CI / frontend (pull_request) Successful in 2m33s
CI / mutation (pull_request) Successful in 5m16s
CI / verify-stack (pull_request) Successful in 7m25s
Add the Ingetrokken terminal status, Registration.Withdraw() (open-only, idempotent), the

WithdrawRegistration handler, and the domain endpoint. Cancelling the running Flowable process

(so the case leaves the werkbak) and the owner-scoped BFF/self-service action are follow-up

sub-slices (S-11b/S-11c).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:53:31 +02:00
98abba8b22 test(domain): a citizen can withdraw an open registration → INGETROKKEN (refs #12)
Withdraw is allowed from INGEDIEND or IN_BEHANDELING, needs no zaak, is idempotent, and is

rejected once the registration has been decided (INGESCHREVEN/AFGEWEZEN). The WithdrawRegistration

handler loads, withdraws, and persists; a repeated withdrawal is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:43:22 +02:00
44 changed files with 52 additions and 1699 deletions

View File

@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by # Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013). # service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80 EXPOSE 80

View File

@@ -17,9 +17,5 @@ FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject). # No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80 EXPOSE 80

View File

@@ -1,17 +0,0 @@
#!/bin/sh
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
#
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
#
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
# entrypoint executes or sources it.
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
echo "portal-nginx-resolver: set resolver to $ns"
fi

View File

@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by # Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010). # service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80 EXPOSE 80

View File

@@ -3,29 +3,9 @@
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1> <utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) { @if (submitted()) {
@if (withdrawn()) { <p utrecht-paragraph role="status">
<p utrecht-paragraph role="status"> Uw registratie is ontvangen. Referentie: {{ reference() }}.
Uw registratie met referentie {{ reference() }} is ingetrokken. </p>
</p>
} @else {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (withdrawFailed()) {
<p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[disabled]="withdrawing()"
(click)="withdraw()"
>
Trek aanvraag in
</button>
}
} @else { } @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p> <p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) { @if (failed()) {

View File

@@ -17,22 +17,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)),
) {
return { return {
post, post,
withdraw,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ { provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
provide: BffApiV1Service,
useValue: {
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
},
},
], ],
}; };
} }
@@ -66,36 +56,6 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy(); 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 () => { 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 // 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. // html-has-lang rule reflects the app, not the bare jsdom document.

View File

@@ -6,8 +6,7 @@ import { UtrechtComponentsModule } from 'ui';
/** /**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG * 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- * 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). After * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*/ */
@Component({ @Component({
selector: 'app-registration-page', selector: 'app-registration-page',
@@ -23,9 +22,6 @@ export class RegistrationPage {
protected readonly reference = signal<string | undefined>(undefined); protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false); protected readonly submitted = signal(false);
protected readonly failed = signal(false); protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
@@ -43,24 +39,4 @@ 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);
},
});
}
} }

View File

@@ -1,72 +0,0 @@
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Respellion engineering
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
(behandel-portal wiring, the Beoordelen user task)
## Context
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
Two questions shape this sub-slice.
1. **How does the case get cancelled — in code, or in the BPMN model?**
2. **How does a withdrawal correlate to the right running process instance?**
## Decision
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
Targeting the subscribed execution (not the user task's execution — a message boundary event's
subscription lives on its own execution) is what makes the correlation land.
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
completes its task best-effort.
## Consequences
**Positive**
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
that talks to Flowable and does not delete instances behind the model's back.
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
**Negative / costs**
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
reference at these volumes; a process-level interrupting event subprocess would close the gap and
is an additive follow-up if it matters.
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
(consistent with ADR-0009).
## Alternatives considered
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
not the process. The message event keeps the cancellation a first-class part of the workflow.
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
change if the race proves to matter.

View File

@@ -1,77 +0,0 @@
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
pattern on `Beoordelen`).
## Context
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
Two forces shape this.
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
escalation happens alongside it.
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
(§8.2)" posture since ADR-0009.
## Decision
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
behandelaar group to teamlead.**
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
belongs to the teamlead; its history and variables are untouched.
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
`OpenZaakAanmaken` worker.
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
## Consequences
**Positive**
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
timer.
**Negative / costs**
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
on redelivery.
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
## Alternatives considered
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
surface to the engine for no capability the external-worker route lacks.
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.

View File

@@ -275,71 +275,3 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak → > 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 > 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. > 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.
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
its identity throughout — only who may claim it changes.
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
verify-domain check automates):
```bash
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
# but NOT claimed.
#
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
FL=http://localhost:8090/flowable-rest/service
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
-H 'Content-Type: application/json' -d '{"action":"move"}'
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
-H 'Content-Type: application/json' -d '{"action":"execute"}'
#
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
```
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen``BeoordelingEscaleren`
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
candidate group behandelaar → teamlead (§8.2).
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
> and reassignment are asserted live by the verify-domain check.

View File

@@ -151,19 +151,3 @@ 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). 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 The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path. 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`.

View File

@@ -17,12 +17,7 @@
# #
# Port map (host): # Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST # 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only) # 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
#
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
services: services:
@@ -210,12 +205,6 @@ services:
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true" KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true" KC_HTTP_ENABLED: "true"
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports: ports:
- "8180:8080" - "8180:8080"
volumes: volumes:
@@ -306,14 +295,6 @@ services:
context: ../services/bff context: ../services/bff
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/bff:dev image: register-referentie/bff:dev
environment:
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:
- "8080:8080" - "8080:8080"
healthcheck: healthcheck:
@@ -322,39 +303,6 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
start_period: 10s start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg] networks: [cg]
# ── Read projection (S-06) ──────────────────────────────────────────────── # ── Read projection (S-06) ────────────────────────────────────────────────
@@ -380,10 +328,6 @@ services:
image: register-referentie/event-subscriber:dev image: register-referentie/event-subscriber:dev
environment: environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
# canonical compose).
Acl__BaseUrl: http://acl:8080/
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports: ports:
- "8110:8080" - "8110:8080"
@@ -396,8 +340,6 @@ services:
depends_on: depends_on:
projection-db: projection-db:
condition: service_healthy condition: service_healthy
acl:
condition: service_healthy
networks: [cg] networks: [cg]
projection-api: projection-api:
@@ -420,73 +362,6 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}

View File

@@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
fl_base="http://$(ip "$fl"):8080/flowable-rest/service" fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}" reg_id="${loc##*/}"
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on # Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. # Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
try: try:
d=json.load(sys.stdin) d=json.load(sys.stdin)
except Exception: except Exception:
@@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id="" task_id=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")" task_id="$(printf '%s' "$resp" | task_for_reg)"
[ -n "$task_id" ] && break [ -n "$task_id" ] && break
sleep 2 sleep 2
done done
@@ -115,124 +115,7 @@ flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/j
echo ">> asserting the process finished (no Beoordelen task remains for the registration)" echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" still="$(printf '%s' "$resp" | task_for_reg)"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
echo ">> submitting a second registration to withdraw"
loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
echo ">> polling Flowable for its Beoordelen task"
task_id2=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -n "$task_id2" ] && break
sleep 2
done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -z "$still2" ] && { gone=1; break; }
sleep 2
done
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
echo ">> submitting a third registration to escalate"
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
reg_id3="${loc3##*/}"
echo ">> third registration $reg_id3"
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
t=next((t for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
candidate_groups() { python3 -c "import sys,json
try:
links=json.load(sys.stdin)
except Exception:
links=[]
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
# The first job id in a management jobs/timer-jobs response on stdin.
first_job_id() { python3 -c "import sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
print(((d.get('data') or [{}])[0]).get('id',''))"; }
echo ">> polling Flowable for its Beoordelen task"
task_id3=""; pid3=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
[ -n "$task_id3" ] && break
sleep 2
done
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
echo ">> asserting the task starts out claimable by the behandelaar group"
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
echo ">> firing the 14-day boundary timer early via the management API"
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
if [ -n "$async_id" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
escalated=""
for _ in $(seq 1 30); do
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
[ "$groups" = "teamlead" ] && { escalated=1; break; }
sleep 2
done
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
exit 0 exit 0

View File

@@ -192,40 +192,6 @@ export class BffApiV1Service {
); );
} }
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdWithdraw<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}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
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>>;

View File

@@ -22,11 +22,6 @@ public interface IDomainClient
{ {
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default); Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). 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 rather than a 500.</summary>
Task<bool> WithdrawRegistrationAsync(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);
@@ -52,17 +47,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status); return new SubmitAccepted(dto.RegistrationId, dto.Status);
} }
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/withdraw", 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) ?? [];

View File

@@ -86,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized); .Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
app.MapPost("/self-service/registrations/{id}/withdraw", 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 withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
return withdrawn ? 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) =>
{ {

View File

@@ -82,18 +82,6 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
/// (false → 404). Tests set this to exercise the relay.</summary>
public bool WithdrawSucceeds { get; set; } = true;
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
Withdrawn = (registrationId, bsn);
return Task.FromResult(WithdrawSucceeds);
}
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)

View File

@@ -71,46 +71,5 @@ public class SelfServiceEndpointTests
Assert.Equal("reg-123", body!.RegistrationId); Assert.Equal("reg-123", body!.RegistrationId);
} }
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_withdrawal_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Withdrawn);
}
[Fact]
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
}
[Fact]
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
{
using var factory = new BffFactory();
factory.Domain.WithdrawSucceeds = false;
var response = await factory.CreateClient().SendAsync(Withdraw(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);
} }

View File

@@ -30,37 +30,6 @@
} }
} }
}, },
"/self-service/registrations/{id}/withdraw": {
"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": [

View File

@@ -21,7 +21,6 @@ builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>(); builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
@@ -31,13 +30,9 @@ builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<Werkbak>(); builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion. // The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>(); builder.Services.AddHostedService<OpenZaakJobPump>();
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
// each overdue beoordeling to the teamlead (S-14).
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
var app = builder.Build(); var app = builder.Build();
@@ -81,19 +76,16 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
}); });
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back, // Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF // advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is // to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
// 404 (indistinguishable, so ownership isn't leaked). Idempotent. // process is a later sub-slice (S-11b).
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) => app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
{ {
if (!Guid.TryParse(id, out var guid)) if (!Guid.TryParse(id, out var guid))
return Results.NotFound(); return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn)) await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." }); return Results.NoContent();
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == WithdrawOutcome.Withdrawn ? 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
@@ -121,8 +113,6 @@ public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit); public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(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;

View File

@@ -15,14 +15,6 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id. /// aggregate. Returns the process instance id.
/// </summary> /// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -86,10 +78,3 @@ public interface IRegistrationStore
/// it) and the registration id it carries as a process variable. /// it) and the registration id it carries as a process variable.
/// </summary> /// </summary>
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId); public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
/// <summary>
/// An acquired <c>BeoordelingEscaleren</c> escalation job (S-14): the Flowable job id and the process
/// instance whose still-open <c>Beoordelen</c> task must be reassigned from behandelaar to teamlead
/// once the 14-day boundary timer fires (ADR-0015).
/// </summary>
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);

View File

@@ -1,5 +1,3 @@
using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the /// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
@@ -10,8 +8,7 @@ public sealed record WerkbakItem(string RegistrationId, string Bsn, string Statu
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the /// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) — /// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task /// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
/// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling /// whose registration the domain doesn't know is skipped rather than invented.
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
/// </summary> /// </summary>
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
{ {
@@ -23,7 +20,7 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
foreach (var task in open) foreach (var task in open)
{ {
var registration = await store.GetAsync(task.RegistrationId, ct); var registration = await store.GetAsync(task.RegistrationId, ct);
if (registration is null || !IsOpenForBeoordeling(registration.Status)) if (registration is null)
continue; continue;
items.Add(new WerkbakItem( items.Add(new WerkbakItem(
@@ -32,9 +29,4 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
return items; return items;
} }
// Only registrations still open for a decision belong in the werkbak; a terminal one (decided or
// withdrawn — S-11) whose Beoordelen task has not yet been cleared must not surface to a behandelaar.
private static bool IsOpenForBeoordeling(RegistrationStatus status)
=> status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling;
} }

View File

@@ -2,54 +2,31 @@ using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in"). /// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF): public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// only the registration's own bsn may withdraw it.</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a withdrawal request.</summary>
public enum WithdrawOutcome
{
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
Withdrawn,
/// <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> /// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process /// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the /// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again).
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn /// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the /// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only —
/// registration never started a process the withdrawal still stands (the process cancel is skipped), /// mirroring how the beoordeling's rejection deferred its zaak propagation.
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary> /// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow) public sealed class WithdrawRegistration(IRegistrationStore store)
{ {
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct); var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which). // A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again.
if (registration is null || registration.Bsn != command.Bsn)
return WithdrawOutcome.NotFound;
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken) if (registration.Status == RegistrationStatus.Ingetrokken)
return WithdrawOutcome.Withdrawn; return;
registration.Withdraw(); registration.Withdraw();
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
if (registration.ProcessInstanceId is not null)
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
return WithdrawOutcome.Withdrawn;
} }
} }

View File

@@ -1,37 +0,0 @@
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// One poll tick of the beoordeling-escalation worker (S-14, ADR-0015): acquire the parked
/// <c>BeoordelingEscaleren</c> jobs — the tokens the 14-day boundary timer on <c>Beoordelen</c> spawns
/// — reassign each instance's still-open <c>Beoordelen</c> task to the teamlead, and complete the job.
/// A job that fails is logged and left un-completed so Flowable redelivers it (§8.6). Split out from
/// the hosted pump so the acquire→reassign→complete logic is unit-testable without a running host.
/// </summary>
public sealed class BeoordelingEscalatieProcessor(
IBeoordelingEscalatieClient client,
ILogger<BeoordelingEscalatieProcessor> logger)
{
/// <summary>Acquire and process up to <paramref name="maxJobs"/> escalations. Returns the number acquired.</summary>
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
{
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(maxJobs, ct);
foreach (var job in jobs)
{
try
{
await client.ReassignBeoordelingToTeamleadAsync(job.ProcessInstanceId, ct);
await client.CompleteBeoordelingEscalatieJobAsync(job.JobId, ct);
}
catch (Exception ex)
{
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
logger.LogError(ex, "BeoordelingEscaleren job {JobId} failed; leaving it for redelivery.", job.JobId);
}
}
return jobs.Count;
}
}

View File

@@ -1,49 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// The hosted polling loop of the beoordeling-escalation worker (S-14, ADR-0015): on an interval it
/// resolves a scoped <see cref="BeoordelingEscalatieProcessor"/> and asks it to drain the parked
/// <c>BeoordelingEscaleren</c> jobs. A deliberately thin shell — all acquire/reassign/complete logic
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
/// and loop resilience. Structurally identical to <see cref="OpenZaakJobPump"/>.
/// </summary>
public sealed class BeoordelingEscalatiePump(
IServiceScopeFactory scopeFactory,
FlowableOptions options,
ILogger<BeoordelingEscalatiePump> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<BeoordelingEscalatieProcessor>();
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
logger.LogError(ex, "BeoordelingEscaleren job poll failed; retrying after the poll interval.");
}
try
{
await Task.Delay(options.PollInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}

View File

@@ -15,18 +15,14 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary> /// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient : IWorkflowClient, IExternalWorkerClient, IUserTaskClient
{ {
private const string Topic = "OpenZaakAanmaken"; private const string Topic = "OpenZaakAanmaken";
private const string EscalatieTopic = "BeoordelingEscaleren";
private const string ProcessDefinitionKey = "registratie"; private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen"; private const string BeoordelenTaskKey = "Beoordelen";
private const string BehandelaarGroup = "behandelaar";
private const string TeamleadGroup = "teamlead";
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string ZaakUrlVariable = "zaakUrl"; private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit"; private const string BesluitVariable = "besluit";
private const string IngetrokkenMessage = "RegistratieIngetrokken";
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -89,84 +85,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the
// interrupting message event's own execution — NOT the user task's), then deliver
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
var subscribed = await GetAsync<ExecutionQueryResult>(
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
ct);
var execution = subscribed?.Data?.FirstOrDefault();
if (execution is null)
return;
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
{
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredEscalatieJob>>(
"external-job-api/acquire/jobs", request, ct) ?? [];
return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))];
}
public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
// The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting
// boundary timer); find that task in this instance so we can move it to the teamlead. If the
// behandelaar completed it just before the timer fired there is nothing to reassign — a
// best-effort no-op (the timer/completion race, cf. §8.6).
var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey);
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
"service/query/tasks", query, ct);
var task = page?.Data?.FirstOrDefault();
if (task is null)
return;
// Add teamlead, then drop behandelaar: the task now belongs to the teamlead group.
using (var added = await SendAsync(
$"service/runtime/tasks/{task.Id}/identitylinks",
new IdentityLinkRequest(TeamleadGroup, "candidate"), ct))
added.EnsureSuccessStatusCode();
await DeleteAsync(
$"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct);
}
public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
using var response = await SendAsync(
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
private async Task DeleteAsync(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{ {
using var response = await SendAsync(path, body, ct); using var response = await SendAsync(path, body, ct);
@@ -174,9 +92,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
return await response.Content.ReadFromJsonAsync<TResponse>(ct); return await response.Content.ReadFromJsonAsync<TResponse>(ct);
} }
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null) private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
{ {
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path)) var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{ {
Content = JsonContent.Create(body), Content = JsonContent.Create(body),
}; };
@@ -206,14 +124,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey, [property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables); [property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
private sealed record TaskByInstanceQueryRequest(
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey);
private sealed record IdentityLinkRequest(
[property: JsonPropertyName("group")] string Group,
[property: JsonPropertyName("type")] string Type);
private sealed record ClaimTaskRequest( private sealed record ClaimTaskRequest(
[property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("assignee")] string Assignee); [property: JsonPropertyName("assignee")] string Assignee);
@@ -222,10 +132,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables); [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record MessageEventRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("messageName")] string MessageName);
private sealed record TaskQueryResult( private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data); [property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
@@ -248,15 +154,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredEscalatieJob(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId);
private sealed record AcquiredJob( private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)

View File

@@ -16,23 +16,3 @@ public interface IExternalWorkerClient
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary> /// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default); Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
} }
/// <summary>
/// The escalation side of the Workflow Client (S-14): the <c>BeoordelingEscaleren</c> external-worker
/// jobs parked by the 14-day boundary timer on <c>Beoordelen</c>, and the reassignment they drive.
/// Kept separate from <see cref="IExternalWorkerClient"/> (interface segregation) so the OpenZaak
/// worker never sees escalation. Implemented by <see cref="FlowableWorkflowClient"/> — the only code
/// that talks to Flowable (§8.2, ADR-0015).
/// </summary>
public interface IBeoordelingEscalatieClient
{
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>BeoordelingEscaleren</c> jobs.</summary>
Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default);
/// <summary>Reassign the still-open <c>Beoordelen</c> task in the given process instance from the
/// behandelaar group to teamlead. Best-effort no-op if the task is no longer open.</summary>
Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default);
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
}

View File

@@ -1,82 +0,0 @@
using Big.Application;
using Big.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Big.Tests;
// S-14 (#15): the escalation drain loop. Mirrors OpenZaakJobProcessor — acquire the parked
// BeoordelingEscaleren jobs, reassign each instance's Beoordelen task to teamlead, then complete the
// job. A job whose reassignment fails is logged and left un-completed for Flowable to redeliver (§8.6).
public class BeoordelingEscalatieProcessorTests
{
/// <summary>A fake escalation client: scripts the jobs to acquire, records reassignments and
/// completions, and can be told to throw on reassigning a given instance.</summary>
private sealed class FakeEscalatieClient(params EscalatieJob[] jobs) : IBeoordelingEscalatieClient
{
public int AcquireCount { get; private set; }
public List<string> Reassigned { get; } = [];
public List<string> Completed { get; } = [];
public string? ThrowOnInstance { get; set; }
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
{
AcquireCount++;
return Task.FromResult<IReadOnlyList<EscalatieJob>>(jobs.Take(maxJobs).ToList());
}
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
if (processInstanceId == ThrowOnInstance)
throw new InvalidOperationException("reassign failed");
Reassigned.Add(processInstanceId);
return Task.CompletedTask;
}
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
Completed.Add(jobId);
return Task.CompletedTask;
}
}
[Fact]
public async Task Acquires_an_escalation_reassigns_to_teamlead_and_completes_the_job()
{
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1"));
var acquired = await new BeoordelingEscalatieProcessor(
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
Assert.Equal(1, acquired);
Assert.Equal("pi-1", Assert.Single(client.Reassigned));
Assert.Equal("job-9", Assert.Single(client.Completed));
}
[Fact]
public async Task A_failing_reassign_is_left_uncompleted_for_flowable_to_redeliver()
{
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1")) { ThrowOnInstance = "pi-1" };
var logger = new CapturingLogger<BeoordelingEscalatieProcessor>();
var acquired = await new BeoordelingEscalatieProcessor(client, logger).PumpOnceAsync(5);
Assert.Equal(1, acquired);
Assert.Empty(client.Completed);
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
Assert.Contains("job-9", error.Message);
}
[Fact]
public async Task Does_nothing_but_poll_when_there_are_no_escalations()
{
var client = new FakeEscalatieClient();
var acquired = await new BeoordelingEscalatieProcessor(
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
Assert.Equal(0, acquired);
Assert.Equal(1, client.AcquireCount);
Assert.Empty(client.Completed);
}
}

View File

@@ -32,7 +32,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient : IWorkflowClient
{ {
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -40,12 +39,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(processInstanceId); return Task.FromResult(processInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of /// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of

View File

@@ -242,61 +242,6 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{expected}\"", capture.Body); Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
} }
[Fact]
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
{
HttpRequestMessage? getReq = null;
HttpRequestMessage? putReq = null;
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.WithdrawProcessAsync("pi-1");
// 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Equal(HttpMethod.Put, putReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
}
[Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
{
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
}
[Fact] [Fact]
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
{ {
@@ -306,108 +251,4 @@ public class FlowableWorkflowClientTests
await Assert.ThrowsAsync<HttpRequestException>( await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren)); () => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
} }
// ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ───────────────
// The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as
// an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open
// Beoordelen task from the behandelaar group to teamlead, then completes the escalation job.
[Fact]
public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
"""[{"id":"job-9","processInstanceId":"pi-1"}]"""));
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3);
var job = Assert.Single(jobs);
Assert.Equal("job-9", job.JobId);
Assert.Equal("pi-1", job.ProcessInstanceId);
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
capture.Seen!.RequestUri!.ToString());
Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body);
Assert.Contains("\"numberOfTasks\":3", capture.Body);
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
}
[Theory]
[InlineData("[]")]
[InlineData("null")]
public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, body));
Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1));
Assert.NotNull(capture.Seen);
}
[Fact]
public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead()
{
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
var client = Client(new StubHandler(async req =>
{
requests.Add((req.Method, req.RequestUri!.ToString(),
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
// The task query returns the still-open Beoordelen task parked in this instance.
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
? new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""",
Encoding.UTF8, "application/json"),
}
: new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
// 1. Find the still-open Beoordelen task in this process instance.
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
Assert.Equal(HttpMethod.Post, query.Method);
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body);
// 2. Add teamlead as a candidate group on that task.
var add = requests.Single(r => r.Method == HttpMethod.Post
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks"));
Assert.Contains("\"group\":\"teamlead\"", add.Body);
Assert.Contains("\"type\":\"candidate\"", add.Body);
// 3. Remove behandelaar as a candidate group — the task now belongs to teamlead.
Assert.Contains(requests, r => r.Method == HttpMethod.Delete
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate"));
}
[Fact]
public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open()
{
// The behandelaar completed it just before the timer fired: nothing to reassign, no throw.
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
});
}));
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
Assert.DoesNotContain(HttpMethod.Delete, methods);
}
[Fact]
public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await client.CompleteBeoordelingEscalatieJobAsync("job-9");
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
}
} }

View File

@@ -46,19 +46,4 @@ public class WerkbakTests
Assert.Empty(await werkbak.GetAsync()); Assert.Empty(await werkbak.GetAsync());
} }
[Fact]
public async Task Skips_a_task_whose_registration_is_no_longer_open()
{
// A withdrawn registration (S-11) may still have a Beoordelen task lingering until the workflow
// cancels it; the werkbak lists only registrations still open for beoordeling, so it drops off.
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
registration.Withdraw();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var werkbak = new Werkbak(tasks, store);
Assert.Empty(await werkbak.GetAsync());
}
} }

View File

@@ -5,101 +5,40 @@ namespace Big.Tests;
public class WithdrawRegistrationTests public class WithdrawRegistrationTests
{ {
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 WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
[Fact] [Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted(); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store);
var outcome = await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
var saved = await store.GetAsync(registration.Id); var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
} }
[Fact]
public async Task Withdrawing_cancels_the_running_process()
{
var store = new FakeRegistrationStore();
var registration = Submitted("proc-42");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id));
// The process the registration recorded at submit is cancelled (ADR-0014).
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task A_different_bsn_cannot_withdraw_the_registration()
{
// Owner-scoping: only the zorgprofessional who submitted may withdraw. Another bsn is told
// NotFound (we don't reveal the registration exists) and nothing is changed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(RegistrationStatus.Ingediend, (await store.GetAsync(registration.Id))!.Status);
Assert.Equal(0, store.SaveCount);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact] [Fact]
public async Task Rejects_a_null_command_without_touching_the_store() public async Task Rejects_a_null_command_without_touching_the_store()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
} }
[Fact] [Fact]
public async Task Withdrawing_an_unknown_registration_is_not_found() public async Task Withdrawing_an_unknown_registration_throws()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store);
var outcome = await handler.HandleAsync(Command(RegistrationId.New())); var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
Assert.Equal(WithdrawOutcome.NotFound, outcome); Assert.Contains("No registration", ex.Message);
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
} }
@@ -107,16 +46,14 @@ public class WithdrawRegistrationTests
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent() public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted(); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var workflow = new FakeWorkflowClient(); var handler = new WithdrawRegistration(store);
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
var second = await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again. // The second withdrawal is a no-op: the aggregate is not persisted again.
Assert.Equal(WithdrawOutcome.Withdrawn, second);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
} }

View File

@@ -4,8 +4,7 @@
"test-projects": ["Big.Tests/Big.Tests.csproj"], "test-projects": ["Big.Tests/Big.Tests.csproj"],
"reporters": ["progress", "html"], "reporters": ["progress", "html"],
"mutate": [ "mutate": [
"!**/OpenZaakJobPump.cs", "!**/OpenZaakJobPump.cs"
"!**/BeoordelingEscalatiePump.cs"
], ],
"thresholds": { "thresholds": {
"high": 95, "high": 95,

View File

@@ -22,9 +22,6 @@
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" /> <ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" /> <ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" /> <ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
<!-- The beoordeling-escalation scenario drives the escalation worker (Infrastructure), as the
ACL scenario drives Acl.Infrastructure — escalation has no domain-aggregate surface (S-14). -->
<ProjectReference Include="..\..\services\domain\Big.Infrastructure\Big.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" /> <ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,22 +0,0 @@
# language: en
# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
Feature: Een beoordeling escaleren
Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
zodat aanvragen niet blijven liggen.
Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
Given a registration parked at the Beoordelen task for the behandelaar
When the 14-day escalation timer fires
And the escalation worker runs
Then the beoordeling is reassigned to the teamlead
Scenario: Een tijdig afgeronde beoordeling escaleert niet
Given a registration parked at the Beoordelen task for the behandelaar
When the behandelaar completes the beoordeling before the timer fires
And the 14-day escalation timer fires
And the escalation worker runs
Then the beoordeling stays with the behandelaar

View File

@@ -1,22 +0,0 @@
# 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

View File

@@ -1,44 +0,0 @@
using Acceptance.Support;
using Big.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenBeoordelingEscaleren.feature</c> (S-14). Drives the escalation worker
/// (<see cref="BeoordelingEscalatieProcessor"/>) against an in-memory Flowable stand-in; one instance
/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.</summary>
[Binding]
[Scope(Feature = "Een beoordeling escaleren")]
public sealed class EenBeoordelingEscalerenSteps
{
private readonly InMemoryEscalatieClient _flowable = new();
private string _processInstanceId = "";
[Given("a registration parked at the Beoordelen task for the behandelaar")]
public void GivenARegistrationParkedForTheBehandelaar()
=> _processInstanceId = _flowable.ParkBeoordeling();
[When("the 14-day escalation timer fires")]
public void WhenTheEscalationTimerFires()
=> _flowable.FireEscalationTimer(_processInstanceId);
[When("the behandelaar completes the beoordeling before the timer fires")]
public void WhenTheBehandelaarCompletesBeforeTheTimer()
=> _flowable.CompleteBeoordeling(_processInstanceId);
[When("the escalation worker runs")]
public async Task WhenTheEscalationWorkerRuns()
=> await new BeoordelingEscalatieProcessor(
_flowable, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
[Then("the beoordeling is reassigned to the teamlead")]
public void ThenTheBeoordelingIsReassignedToTheTeamlead()
=> Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
[Then("the beoordeling stays with the behandelaar")]
public void ThenTheBeoordelingStaysWithTheBehandelaar()
=> Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
}

View File

@@ -1,59 +0,0 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (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.</summary>
[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);
}

View File

@@ -69,9 +69,6 @@ public sealed class CapturingDomainClient : IDomainClient
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
} }
public Task<bool> WithdrawRegistrationAsync(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>>([]);

View File

@@ -1,6 +1,5 @@
using Big.Application; using Big.Application;
using Big.Domain; using Big.Domain;
using Big.Infrastructure;
namespace Acceptance.Support; namespace Acceptance.Support;
@@ -12,19 +11,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
public const string StartedProcessInstanceId = "proc-acc-1"; public const string StartedProcessInstanceId = "proc-acc-1";
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(StartedProcessInstanceId); return Task.FromResult(StartedProcessInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, /// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,
@@ -74,59 +66,6 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
} }
} }
/// <summary>An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.</summary>
public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
{
private sealed class ParkedTask
{
public string CandidateGroup { get; set; } = "behandelaar";
public bool IsOpen { get; set; } = true;
}
private readonly Dictionary<string, ParkedTask> _tasks = [];
private readonly List<EscalatieJob> _parked = [];
private int _seq;
/// <summary>A registration parks at Beoordelen, claimable by the behandelaar group.</summary>
public string ParkBeoordeling()
{
var pid = $"pi-{++_seq}";
_tasks[pid] = new ParkedTask();
return pid;
}
/// <summary>The behandelaar completes the beoordeling before the timer fires: the task closes.</summary>
public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
/// <summary>The 14-day boundary timer fires: a non-interrupting token parks an escalation job.</summary>
public void FireEscalationTimer(string processInstanceId)
=> _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
/// <summary>The candidate group that may currently pick the task up.</summary>
public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EscalatieJob>>(_parked.Take(maxJobs).ToList());
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
// No-op if the behandelaar already completed it — the timer/completion race (§8.6).
var task = _tasks[processInstanceId];
if (task.IsOpen)
task.CandidateGroup = "teamlead";
return Task.CompletedTask;
}
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
_parked.RemoveAll(j => j.JobId == jobId);
return Task.CompletedTask;
}
}
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary> /// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
public sealed class InMemoryRegistrationStore : IRegistrationStore public sealed class InMemoryRegistrationStore : IRegistrationStore
{ {

View File

@@ -1,27 +0,0 @@
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();
});

View File

@@ -11,18 +11,7 @@
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
as a user task: the process parks here until a behandelaar claims and completes it with a as a user task: the process parks here until a behandelaar claims and completes it with a
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process `besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
variable so the werkbak can correlate each task back to its aggregate. variable so the werkbak can correlate each task back to its aggregate. -->
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
to the task's execution; the BPMN owns the cancellation path.
S-14 adds escalation: a NON-interrupting boundary timer (P14D) on Beoordelen. If a behandelaar
has not picked the task up within 14 days it fires a parallel token to an external-worker task
(BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the
behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
only changes who may claim it. -->
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
<process id="registratie" name="Registratie ontvangen" isExecutable="true"> <process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/> <startEvent id="start" name="Registratie ontvangen"/>
@@ -40,35 +29,6 @@
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/> <sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
<endEvent id="end" name="Registratie beoordeeld"/> <endEvent id="end" name="Registratie beoordeeld"/>
<!-- Withdrawal (S-11): interrupting message boundary event on Beoordelen. On RegistratieIngetrokken
the task is cancelled and the process ends as "ingetrokken". -->
<boundaryEvent id="Ingetrokken" attachedToRef="Beoordelen" cancelActivity="true">
<messageEventDefinition messageRef="Message_Ingetrokken"/>
</boundaryEvent>
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
<!-- Escalation (S-14): non-interrupting P14D boundary timer on Beoordelen. On timeout a parallel
token runs EscaleerBeoordeling, an external-worker task the Workflow Client picks up to
reassign the still-open Beoordelen task from behandelaar to teamlead (ADR-0015). -->
<boundaryEvent id="EscaleerTimer" attachedToRef="Beoordelen" cancelActivity="false">
<timerEventDefinition>
<timeDuration>P14D</timeDuration>
</timerEventDefinition>
</boundaryEvent>
<sequenceFlow id="flow5" sourceRef="EscaleerTimer" targetRef="EscaleerBeoordeling"/>
<serviceTask id="EscaleerBeoordeling" name="Beoordeling escaleren"
flowable:type="external-worker"
flowable:topic="BeoordelingEscaleren"/>
<sequenceFlow id="flow6" sourceRef="EscaleerBeoordeling" targetRef="endEscaleren"/>
<endEvent id="endEscaleren" name="Beoordeling geëscaleerd"/>
</process> </process>
<bpmndi:BPMNDiagram id="diagram"> <bpmndi:BPMNDiagram id="diagram">
@@ -97,34 +57,6 @@
<omgdi:waypoint x="510" y="115"/> <omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="580" y="115"/> <omgdi:waypoint x="580" y="115"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
<omgdc:Bounds x="435" y="135" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
<omgdc:Bounds x="435" y="220" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
<omgdi:waypoint x="450" y="165"/>
<omgdi:waypoint x="450" y="220"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
<omgdc:Bounds x="480" y="70" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
<omgdc:Bounds x="580" y="20" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
<omgdc:Bounds x="760" y="35" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
<omgdi:waypoint x="495" y="70"/>
<omgdi:waypoint x="495" y="50"/>
<omgdi:waypoint x="580" y="50"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
<omgdi:waypoint x="700" y="50"/>
<omgdi:waypoint x="760" y="50"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>
</definitions> </definitions>