Compare commits
9 Commits
feat/12-wi
...
feat/12-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ec5d28df | |||
| 951bdd8364 | |||
| 8ab38e2816 | |||
| eb1ede4f0c | |||
| 4cc2d9ac80 | |||
| 421683115c | |||
| bfa4d5ba85 | |||
| 2397d9196a | |||
| a34caba9ea |
@@ -3,9 +3,29 @@
|
||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||
|
||||
@if (submitted()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
@if (withdrawn()) {
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||
</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 {
|
||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||
@if (failed()) {
|
||||
|
||||
@@ -17,12 +17,22 @@ class FakeAuth extends AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
||||
function providers(
|
||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: {
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -56,6 +66,36 @@ describe('RegistrationPage', () => {
|
||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||
const { withdraw, providers: p } = providers();
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||
|
||||
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||
|
||||
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
|
||||
/**
|
||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-registration-page',
|
||||
@@ -22,6 +23,9 @@ export class RegistrationPage {
|
||||
protected readonly reference = signal<string | undefined>(undefined);
|
||||
protected readonly submitted = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly withdrawing = signal(false);
|
||||
protected readonly withdrawn = signal(false);
|
||||
protected readonly withdrawFailed = signal(false);
|
||||
|
||||
submit(): void {
|
||||
this.submitting.set(true);
|
||||
@@ -39,4 +43,24 @@ export class RegistrationPage {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
withdraw(): void {
|
||||
const reference = this.reference();
|
||||
if (!reference) {
|
||||
return;
|
||||
}
|
||||
this.withdrawing.set(true);
|
||||
this.withdrawFailed.set(false);
|
||||
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||
next: () => {
|
||||
this.withdrawn.set(true);
|
||||
this.withdrawing.set(false);
|
||||
},
|
||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||
error: () => {
|
||||
this.withdrawFailed.set(true);
|
||||
this.withdrawing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
72
docs/architecture/adr-0014-withdrawal-cancels-the-process.md
Normal file
72
docs/architecture/adr-0014-withdrawal-cancels-the-process.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
@@ -275,3 +275,26 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
|
||||
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||
> drives the behandel portal in place of the old admin endpoint.
|
||||
|
||||
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||
|
||||
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||
behandelaar's werkbak.
|
||||
|
||||
```text
|
||||
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||
# note the "Referentie" on the confirmation.
|
||||
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||
# appears — its Beoordelen task was cancelled.
|
||||
```
|
||||
|
||||
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||
interrupting boundary event ends it → the werkbak drops the case.
|
||||
|
||||
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||
|
||||
@@ -151,3 +151,19 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
|
||||
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||
path.
|
||||
|
||||
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||
|
||||
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||
|
||||
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
#
|
||||
# Port map (host):
|
||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||
# 8100 ACL · 8130 Domain · 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:
|
||||
|
||||
@@ -205,6 +210,12 @@ services:
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_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:
|
||||
- "8180:8080"
|
||||
volumes:
|
||||
@@ -295,6 +306,14 @@ services:
|
||||
context: ../services/bff
|
||||
dockerfile: Dockerfile
|
||||
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:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
@@ -303,6 +322,39 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
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]
|
||||
|
||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||
@@ -362,6 +414,73 @@ services:
|
||||
condition: service_healthy
|
||||
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:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
|
||||
3
infra/local-config/behandel.config.json
Normal file
3
infra/local-config/behandel.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/medewerker"
|
||||
}
|
||||
3
infra/local-config/self-service.config.json
Normal file
3
infra/local-config/self-service.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/digid"
|
||||
}
|
||||
@@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
|
||||
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
|
||||
reg_id="${loc##*/}"
|
||||
|
||||
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
|
||||
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
|
||||
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
|
||||
# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on
|
||||
# stdin. 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
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
except Exception:
|
||||
@@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||
task_id=""
|
||||
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_id="$(printf '%s' "$resp" | task_for_reg)"
|
||||
task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||
[ -n "$task_id" ] && break
|
||||
sleep 2
|
||||
done
|
||||
@@ -115,7 +115,46 @@ 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)"
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
|
||||
still="$(printf '%s' "$resp" | task_for_reg)"
|
||||
still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||
[ -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"
|
||||
|
||||
# ── 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)"
|
||||
exit 0
|
||||
|
||||
@@ -192,6 +192,40 @@ 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?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
|
||||
@@ -22,6 +22,11 @@ public interface IDomainClient
|
||||
{
|
||||
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>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -47,6 +52,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
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)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
|
||||
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.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).
|
||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
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 Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -71,5 +71,46 @@ public class SelfServiceEndpointTests
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -76,16 +76,19 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
|
||||
});
|
||||
|
||||
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
|
||||
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
|
||||
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
|
||||
// process is a later sub-slice (S-11b).
|
||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
|
||||
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF
|
||||
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
|
||||
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
|
||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
|
||||
return Results.NoContent();
|
||||
if (string.IsNullOrWhiteSpace(body?.Bsn))
|
||||
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
|
||||
|
||||
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
|
||||
@@ -113,6 +116,8 @@ public sealed record SubmitRegistrationRequest(string Bsn);
|
||||
|
||||
public sealed record DecideRequest(string Besluit);
|
||||
|
||||
public sealed record WithdrawRequest(string Bsn);
|
||||
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -15,6 +15,14 @@ public interface IWorkflowClient
|
||||
/// aggregate. Returns the process instance id.
|
||||
/// </summary>
|
||||
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>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
|
||||
@@ -8,7 +10,8 @@ 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
|
||||
/// 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
|
||||
/// whose registration the domain doesn't know is skipped rather than invented.
|
||||
/// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling
|
||||
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
|
||||
/// </summary>
|
||||
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||
{
|
||||
@@ -20,7 +23,7 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||
foreach (var task in open)
|
||||
{
|
||||
var registration = await store.GetAsync(task.RegistrationId, ct);
|
||||
if (registration is null)
|
||||
if (registration is null || !IsOpenForBeoordeling(registration.Status))
|
||||
continue;
|
||||
|
||||
items.Add(new WerkbakItem(
|
||||
@@ -29,4 +32,9 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,31 +2,54 @@ using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
|
||||
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
|
||||
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
|
||||
/// 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>
|
||||
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
||||
/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered
|
||||
/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again).
|
||||
/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later
|
||||
/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only —
|
||||
/// mirroring how the beoordeling's rejection deferred its zaak propagation.
|
||||
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
|
||||
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
|
||||
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
|
||||
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
|
||||
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
|
||||
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
|
||||
/// </summary>
|
||||
public sealed class WithdrawRegistration(IRegistrationStore store)
|
||||
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
||||
{
|
||||
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
||||
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct);
|
||||
|
||||
// A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again.
|
||||
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
|
||||
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)
|
||||
return;
|
||||
return WithdrawOutcome.Withdrawn;
|
||||
|
||||
registration.Withdraw();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
private const string RegistrationIdVariable = "registrationId";
|
||||
private const string ZaakUrlVariable = "zaakUrl";
|
||||
private const string BesluitVariable = "besluit";
|
||||
private const string IngetrokkenMessage = "RegistratieIngetrokken";
|
||||
|
||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -85,6 +86,35 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
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();
|
||||
}
|
||||
|
||||
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<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(path, body, ct);
|
||||
@@ -92,9 +122,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
|
||||
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null)
|
||||
{
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||
{
|
||||
Content = JsonContent.Create(body),
|
||||
};
|
||||
@@ -132,6 +162,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record MessageEventRequest(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("messageName")] string MessageName);
|
||||
|
||||
private sealed record TaskQueryResult(
|
||||
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
|
||||
|
||||
@@ -154,6 +188,11 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
|
||||
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 AcquiredJob(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
||||
|
||||
@@ -32,6 +32,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
: IWorkflowClient
|
||||
{
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -39,6 +40,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
StartedFor = registrationId;
|
||||
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
|
||||
|
||||
@@ -242,6 +242,61 @@ public class FlowableWorkflowClientTests
|
||||
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]
|
||||
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
|
||||
@@ -46,4 +46,19 @@ public class WerkbakTests
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,40 +5,101 @@ namespace Big.Tests;
|
||||
|
||||
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]
|
||||
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
var handler = new WithdrawRegistration(store);
|
||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
|
||||
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]
|
||||
public async Task Rejects_a_null_command_without_touching_the_store()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var handler = new WithdrawRegistration(store);
|
||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdrawing_an_unknown_registration_throws()
|
||||
public async Task Withdrawing_an_unknown_registration_is_not_found()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var handler = new WithdrawRegistration(store);
|
||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
|
||||
Assert.Contains("No registration", ex.Message);
|
||||
var outcome = await handler.HandleAsync(Command(RegistrationId.New()));
|
||||
|
||||
Assert.Equal(WithdrawOutcome.NotFound, outcome);
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
}
|
||||
|
||||
@@ -46,14 +107,16 @@ public class WithdrawRegistrationTests
|
||||
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
var handler = new WithdrawRegistration(store);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new WithdrawRegistration(store, workflow);
|
||||
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(Command(registration.Id));
|
||||
var second = await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
// The second withdrawal is a no-op: the aggregate is not persisted again.
|
||||
// The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again.
|
||||
Assert.Equal(WithdrawOutcome.Withdrawn, second);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
@@ -0,0 +1,22 @@
|
||||
# language: en
|
||||
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
|
||||
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
|
||||
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
|
||||
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
|
||||
Feature: Een registratie intrekken
|
||||
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
|
||||
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
|
||||
|
||||
Scenario: De zorgprofessional trekt zijn eigen registratie in
|
||||
Given a submitted registration with a running process
|
||||
When the zorgprofessional withdraws it
|
||||
Then the withdrawal succeeds
|
||||
And the registration has status "INGETROKKEN"
|
||||
And the running process is cancelled
|
||||
|
||||
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
|
||||
Given a submitted registration with a running process
|
||||
When a different zorgprofessional tries to withdraw it
|
||||
Then the withdrawal is reported not found
|
||||
And the registration has status "INGEDIEND"
|
||||
And the running process is not cancelled
|
||||
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
}
|
||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
||||
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)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||
|
||||
|
||||
@@ -11,12 +11,19 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
||||
public const string StartedProcessInstanceId = "proc-acc-1";
|
||||
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
StartedFor = registrationId;
|
||||
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,
|
||||
|
||||
27
tests/e2e/withdrawal.spec.ts
Normal file
27
tests/e2e/withdrawal.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
||||
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
||||
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
|
||||
// then confirms the registration is ingetrokken.
|
||||
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
|
||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||
await page.goto('/');
|
||||
|
||||
await page.locator('#username').fill('jan-burger');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /indienen/i }).click();
|
||||
|
||||
// The BFF accepted it and the page shows the confirmation with the reference.
|
||||
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||
|
||||
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
|
||||
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
|
||||
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
|
||||
|
||||
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
|
||||
});
|
||||
@@ -11,7 +11,13 @@
|
||||
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
|
||||
`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. -->
|
||||
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
||||
|
||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||
|
||||
<startEvent id="start" name="Registratie ontvangen"/>
|
||||
@@ -29,6 +35,16 @@
|
||||
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
||||
|
||||
<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"/>
|
||||
</process>
|
||||
|
||||
<bpmndi:BPMNDiagram id="diagram">
|
||||
@@ -57,6 +73,16 @@
|
||||
<omgdi:waypoint x="510" y="115"/>
|
||||
<omgdi:waypoint x="580" y="115"/>
|
||||
</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:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</definitions>
|
||||
|
||||
Reference in New Issue
Block a user