Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 eeb2a9cb40 fix(infra): don't require the moved timer job to linger in verify-domain (S-14, refs #15)
CI / lint (pull_request) Successful in 1m16s
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 2m30s
CI / mutation (pull_request) Successful in 5m22s
CI / verify-stack (pull_request) Successful in 7m15s
Flowable's async executor picks up the moved timer job and fires the boundary
event before the check can GET /management/jobs, so requiring that async job to
still exist raced and failed. The move alone fires the timer; the explicit
execute is now a best-effort nudge that tolerates the job already being gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:40:40 +02:00
notandClaude Opus 4.8 b17c2c51b1 chore(test): exclude BeoordelingEscalatiePump from mutation (S-14, refs #15)
CI / build (pull_request) Successful in 1m3s
CI / lint (pull_request) Successful in 4m21s
CI / unit (pull_request) Successful in 1m8s
CI / frontend (pull_request) Successful in 2m37s
CI / mutation (pull_request) Successful in 5m14s
CI / verify-stack (pull_request) Failing after 6m36s
The hosted escalation pump is a thin shell with logic in the unit-tested
processor, mirroring the existing OpenZaakJobPump exclusion (§5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:15:51 +02:00
notandClaude Opus 4.8 cb8568c36a docs(workflow): ADR-0015 + demo note for beoordeling escalation (S-14, refs #15)
Record the escalation-via-external-worker decision (ADR-0015, from proposal #98)
and add the S-14 demo walkthrough that fires the 14-day timer early via Flowable's
management API to observe the reassignment to teamlead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:13:53 +02:00
notandClaude Opus 4.8 6457aad6db test(workflow): verify-domain fires the 14-day timer and asserts escalation (S-14, refs #15)
Extend the live verify-domain check: a third registration parks at Beoordelen,
its boundary timer is fired early via Flowable's management API, and the check
polls until the domain escalation worker reassigns the still-open task's
candidate group from behandelaar to teamlead (ADR-0015). Runs on the CI
verify-stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:12:01 +02:00
notandClaude Opus 4.8 f02d2ef568 test(acceptance): beoordeling escalation — both branches (S-14, refs #15)
Gherkin scenarios for the two branches: a beoordeling left unclaimed for 14 days
escalates to the teamlead, and one completed before the timer fires does not.
Driven through the escalation worker against an in-memory Flowable stand-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:10:31 +02:00
notandClaude Opus 4.8 aa6132a6a9 feat(workflow): 14-day beoordeling escalation timer + hosted worker (S-14, refs #15)
Add a non-interrupting P14D boundary timer on the Beoordelen user task that fires
an external-worker task (BeoordelingEscaleren) to the escalation end; wire the
hosted BeoordelingEscalatiePump and register the escalation client/processor in DI.
On timeout the still-open beoordeling is reassigned to teamlead (ADR-0015).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:06:27 +02:00
notandClaude Opus 4.8 33160fa7d0 feat(workflow): escalation drain loop reassigns then completes (S-14, refs #15)
Implement BeoordelingEscalatieProcessor.PumpOnceAsync over the escalation client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:04:53 +02:00
notandClaude Opus 4.8 e2eaa0786d test(workflow): escalation drain loop reassigns then completes (S-14, refs #15)
Failing tests for BeoordelingEscalatieProcessor: acquire the parked
BeoordelingEscaleren jobs, reassign each instance to teamlead, complete the job,
and leave a failed reassignment un-completed for redelivery. PumpOnceAsync is
stubbed to throw so the suite compiles and fails on the new behaviour only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:04:29 +02:00
notandClaude Opus 4.8 8e7a0c86bb feat(workflow): Workflow Client reassigns beoordeling to teamlead (S-14, refs #15)
Implement the BeoordelingEscaleren escalation capability on the Flowable
Workflow Client: acquire escalation jobs, find the still-open Beoordelen task in
the instance and swap its candidate group behandelaar -> teamlead via task
identity links, and complete the job. Segregated onto IBeoordelingEscalatieClient
so the OpenZaak worker is unaffected (ADR-0015).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:03:27 +02:00
notandClaude Opus 4.8 0b31f8db49 test(workflow): beoordeling escalation reassigns to teamlead (S-14, refs #15)
Failing Workflow Client tests for the 14-day escalation capability (ADR
proposal #98): acquire BeoordelingEscaleren external-worker jobs, reassign the
still-open Beoordelen task's candidate group behandelaar -> teamlead, and
complete the escalation job. Methods are stubbed to throw so the suite compiles
and fails on the new behaviour only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:00:52 +02:00
46 changed files with 103 additions and 1898 deletions
+2 -12
View File
@@ -199,19 +199,9 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
### S-10 · Document upload + boundary timer for document timeout (Flow 2) ### S-10 · Document upload + boundary timer for document timeout (Flow 2)
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary: **Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102 **Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the document is stored in the ZGW Documenten (DRC) API and related to the zaak, with all document calls routed through the ACL (§8.1), and the zaak is set to a cancellation status on timeout expiry. Builds on the S-10a trigger/wait. Depends on #102.
**Acceptance:** ACL Documenten gateway integration test; Playwright e2e uploads a real document; the openbaar/zaak reflects the stored document.
### S-11 · Withdrawal (Flow 3) ### S-11 · Withdrawal (Flow 3)
@@ -11,24 +11,6 @@
<p utrecht-paragraph role="status"> <p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}. Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p> </p>
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="providingDocuments()"
(click)="provideDocuments()"
>
Documenten aanleveren
</button>
}
@if (withdrawFailed()) { @if (withdrawFailed()) {
<p utrecht-paragraph role="alert"> <p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw. Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
@@ -20,12 +20,10 @@ 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)), withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
) { ) {
return { return {
post, post,
withdraw, withdraw,
provideDocuments,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ {
@@ -33,7 +31,6 @@ function providers(
useValue: { useValue: {
postSelfServiceRegistrations: post, postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw, postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
}, },
}, },
], ],
@@ -83,37 +80,6 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy(); expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
}); });
it('offers to provide documents after submitting, and doing so confirms', async () => {
const { provideDocuments, 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: /documenten aanleveren/i }));
// The provide-documents call is keyed by the reference the submit returned, and the page confirms.
expect(provideDocuments).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
});
it('surfaces a provide-documents failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(of(undefined)),
vi.fn().mockReturnValue(throwError(() => new Error('documents 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: /documenten aanleveren/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => { it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers( const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
@@ -26,9 +26,6 @@ export class RegistrationPage {
protected readonly withdrawing = signal(false); protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false); protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false); protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
@@ -47,26 +44,6 @@ export class RegistrationPage {
}); });
} }
provideDocuments(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
this.bff.postSelfServiceRegistrationsIdDocuments(reference).subscribe({
next: () => {
this.documentsProvided.set(true);
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
this.providingDocuments.set(false);
},
});
}
withdraw(): void { withdraw(): void {
const reference = this.reference(); const reference = this.reference();
if (!reference) { if (!reference) {
@@ -1,77 +0,0 @@
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
- **Status:** Accepted
- **Date:** 2026-07-17
- **Deciders:** Respellion engineering
- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow
Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process).
## Context
S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling
(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through
CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same
`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes
the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN
governing the registration workflow" (Flowable as a peer orchestration module).
The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client")
suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result
back. That is one option; it is not the only one, and it is not the cleanest.
## Decision
**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process
as a DMN service task (`flowable:type="dmn"`); an exclusive gateway routes on its output. The domain's
only new job is to carry the diploma origin and pass it into the process as a start variable.**
- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin`
→ `route` (`Buitenlands` ⇒ `CBGV_ADVIES`, otherwise `DIRECT`). A DMN service task
(`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between
`OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new
`CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. (A `businessRuleTask` would
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
- **The domain carries the input, not the decision.** The `Registration` aggregate gains a
`DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to
`StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain
never evaluates the DMN and never learns the route — that is the process's concern.
- **Deployed as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
— the resource is stored but no decision is created, so the service task fails at runtime with
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
With the decision present in the DMN repository, the process's DMN service task resolves it across
deployments by key (verified live), so no shared parent deployment id is needed.
## Consequences
**Positive**
- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN
framing); business users can read/adjust the decision table without touching domain code.
- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision
runs inside the process the client already started — no domain→Flowable round-trip for a decision.
- The domain change is minimal and additive: one value on the aggregate, one start variable.
**Negative / costs**
- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on
the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged.
- The DMN and its service-task wiring are validated live (verify-domain drives a foreign
registration through CBGV-advies and a domestic one straight to beoordeling, exercising both
branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests
cover only that the origin is carried into the process.
## Alternatives considered
- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples
the domain to Flowable for a decision and splits the routing across two places (domain computes,
BPMN branches), for no benefit over letting the engine that owns the process own the decision.
- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the
DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data.
@@ -1,90 +0,0 @@
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
- **Status:** Accepted
- **Date:** 2026-07-20
- **Deciders:** Respellion engineering
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
## Context
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
aggregate stays in sync.
## Decision
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
between the gateway and Beoordelen.
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
without persisting again; an unknown registration throws so the job is redelivered.
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
behind the same action.
## Consequences
**Positive**
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
worker trio (worker + processor + pump), one Workflow Client method.
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
introduced in S-10a.
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
**Negative / costs**
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
through the process.
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
## Alternatives considered
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
status — the case would still look open.
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
-91
View File
@@ -343,94 +343,3 @@ candidate group behandelaar → teamlead (§8.2).
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the > 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 > `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
> and reassignment are asserted live by the verify-domain check. > and reassignment are asserted live by the verify-domain check.
## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016)
A registration's diploma origin decides its route. A DMN service task in the registratie
process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a
**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before
beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the
DMN, not in code — a beheerder can read and adjust the decision table directly.
The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to
the domain directly, so the demo drives it through the domain endpoint:
```bash
# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference):
DOM=http://localhost:8080 # domain service
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
#
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
FL=http://localhost:8090/flowable-rest/service
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
#
# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task:
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
-H 'Content-Type: application/json' -d '{"action":"complete"}'
# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen.
```
**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN
DMN service task sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`;
domestic: `Beoordelen` directly (§8.2, ADR-0016).
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
> foreign→CBGV routing are asserted live by the verify-domain check.
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
for the citizen's documents (their diploma). Two things can happen:
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
routing (S-13) → beoordeling.
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
**VERLOPEN** (the case is cancelled).
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
demonstrated by firing the 30-day timer early via the management API.
```bash
DOM=http://localhost:8080 # domain service
FL=http://localhost:8090/flowable-rest/service # flowable-rest
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
-H 'Content-Type: application/json' -d '{"action":"complete"}'
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
JID=$(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/$JID" \
-H 'Content-Type: application/json' -d '{"action":"move"}'
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
```
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
> verify-domain check.
+6 -17
View File
@@ -259,30 +259,19 @@ services:
flowable-init: flowable-init:
image: docker.io/curlimages/curl:latest image: docker.io/curlimages/curl:latest
restart: "no" restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by # registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
# infra/seed-config.sh.
volumes: volumes:
- fl-bpmn:/work:ro - fl-bpmn:/work:ro
command: command:
- sh - sh
- -c - -c
- | - |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments: echo "registratie already deployed; skip"
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN" curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
fi fi
depends_on: depends_on:
flowable-rest: flowable-rest:
+8 -19
View File
@@ -35,35 +35,24 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# Deploys registratie.bpmn (process engine) and diploma-eligibility.dmn (DMN engine) via the REST # Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
# API once flowable-rest is up. Idempotent: skips each if already deployed. # Idempotent: skips if a deployment named "registratie" already exists.
flowable-init: flowable-init:
image: docker.io/curlimages/curl:latest image: docker.io/curlimages/curl:latest
restart: "no" restart: "no"
# registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by # registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
# infra/seed-config.sh.
volumes: volumes:
- fl-bpmn:/work:ro - fl-bpmn:/work:ro
command: command:
- sh - sh
- -c - -c
- | - |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments: echo "registratie already deployed; skip"
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
else else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN" curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
fi fi
depends_on: depends_on:
flowable-rest: flowable-rest:
-114
View File
@@ -93,27 +93,6 @@ print(next((t['id'] for t in (d.get('data') or [])
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; } flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}' query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
# is exercised separately at the end.
complete_wacht() { # reg_id
local rid="$1" wid="" r
for _ in $(seq 1 30); do
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
[ -n "$wid" ] && break
sleep 2
done
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> completed WachtOpDocumenten for $rid (documents received)"
}
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
complete_wacht "$reg_id"
echo ">> polling Flowable for the Beoordelen user task (werkbak)" echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id="" task_id=""
@@ -151,7 +130,6 @@ loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; } [ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}" reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2" echo ">> second registration $reg_id2"
complete_wacht "$reg_id2"
echo ">> polling Flowable for its Beoordelen task" echo ">> polling Flowable for its Beoordelen task"
task_id2="" task_id2=""
@@ -180,50 +158,6 @@ 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; } [ -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)" echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the
# extra CBGVAdvies user task before Beoordelen (the DMN service task sets route=CBGV_ADVIES and the
# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration
# above, which parked straight at Beoordelen. ──────────────────────────────────────────────────────
cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}'
echo ">> submitting a registration with a foreign diploma"
locf="$(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","diplomaOrigin":"Buitenlands"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
reg_idf="${locf##*/}"
echo ">> foreign registration $reg_idf"
complete_wacht "$reg_idf"
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
cbgv_task=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)"
cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -n "$cbgv_task" ] && break
sleep 2
done
[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> CBGVAdvies task $cbgv_task is waiting"
echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")"
[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; }
echo ">> completing the CBGV-advies task"
flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
echo ">> asserting it now advances to Beoordelen"
onward=""
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)"
[ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; }
sleep 2
done
[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; }
echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)"
# ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer # ── 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 # 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 # parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
@@ -264,8 +198,6 @@ except Exception:
d={} d={}
print(((d.get('data') or [{}])[0]).get('id',''))"; } print(((d.get('data') or [{}])[0]).get('id',''))"; }
complete_wacht "$reg_id3"
echo ">> polling Flowable for its Beoordelen task" echo ">> polling Flowable for its Beoordelen task"
task_id3=""; pid3="" task_id3=""; pid3=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
@@ -303,50 +235,4 @@ for _ in $(seq 1 30); do
done 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; } [ -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" echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
# ── S-10a: document timeout. A registration parks at WachtOpDocumenten and — unlike every block above —
# its documents never arrive. We fire its 30-day boundary timer early via the management API; the
# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The
# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ────────────
echo ">> submitting a registration to let its document term lapse"
locv="$(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 "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
reg_idv="${locv##*/}"
echo ">> timeout registration $reg_idv"
echo ">> polling Flowable for its WachtOpDocumenten task"
wacht_id=""; pidv=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
[ -n "$wacht_id" ] && break
sleep 2
done
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
echo ">> firing the 30-day document timer early via the management API"
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
if [ -n "$async_idv" ]; then
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
fi
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
verlopen=""
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
sleep 2
done
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
exit 0 exit 0
+1 -10
View File
@@ -35,21 +35,12 @@ populate() { # volume source(file or dir/.)
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; } [ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
# does not cascade a .dmn bundled in a process .bar into the DMN engine, so we seed both raw files and
# let flowable-init deploy each via its own REST app. We stage them in a temp dir and copy its contents.
stage_flowable_workflows() {
local dir="$1"
cp "$here/../workflows/registratie.bpmn" "$here/../workflows/diploma-eligibility.dmn" "$dir/"
}
for key in "$@"; do for key in "$@"; do
case "$key" in case "$key" in
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;; oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;; nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;; kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;; fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;; *) echo "unknown seed key: $key" >&2; exit 2 ;;
esac esac
done done
@@ -226,40 +226,6 @@ export class BffApiV1Service {
); );
} }
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdDocuments<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}/documents`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/documents`,
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>>;
-16
View File
@@ -27,11 +27,6 @@ public interface IDomainClient
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary> /// 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); Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
/// <summary>Provide the documents the caller's own registration is waiting for ("documenten
/// aanleveren"). 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.</summary>
Task<bool> ProvideDocumentsAsync(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);
@@ -68,17 +63,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return true; return true;
} }
public async Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/documents", 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) ?? [];
-20
View File
@@ -104,26 +104,6 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
// is S-10b — this is the trigger that unblocks the process.
app.MapPost("/self-service/registrations/{id}/documents", 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 provided = await domain.ProvideDocumentsAsync(id, bsn, ct);
return provided ? 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) =>
{ {
-12
View File
@@ -94,18 +94,6 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(WithdrawSucceeds); return Task.FromResult(WithdrawSucceeds);
} }
public (string RegistrationId, string Bsn)? DocumentsProvidedFor { get; private set; }
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
public bool ProvideDocumentsSucceeds { get; set; } = true;
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
{
DocumentsProvidedFor = (registrationId, bsn);
return Task.FromResult(ProvideDocumentsSucceeds);
}
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)
@@ -112,46 +112,5 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
} }
private static HttpRequestMessage ProvideDocuments(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/documents");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_providing_documents_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(ProvideDocuments(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.DocumentsProvidedFor);
}
[Fact]
public async Task Provides_documents_for_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.DocumentsProvidedFor);
}
[Fact]
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
{
using var factory = new BffFactory();
factory.Domain.ProvideDocumentsSucceeds = false;
var response = await factory.CreateClient().SendAsync(ProvideDocuments(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);
} }
-31
View File
@@ -61,37 +61,6 @@
} }
} }
}, },
"/self-service/registrations/{id}/documents": {
"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": [
+2 -33
View File
@@ -22,29 +22,22 @@ builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<Flowa
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.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IRegistratieVerlopenClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>(); builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>(); builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>(); builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<WithdrawRegistration>(); builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<ProvideDocuments>();
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>(); builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
builder.Services.AddScoped<ExpireRegistrationWorker>();
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
// 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 // The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
// each overdue beoordeling to the teamlead (S-14). // each overdue beoordeling to the teamlead (S-14).
builder.Services.AddHostedService<BeoordelingEscalatiePump>(); builder.Services.AddHostedService<BeoordelingEscalatiePump>();
// The document-timeout worker polls the RegistratieVerlopen jobs the 30-day timer on WachtOpDocumenten
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
builder.Services.AddHostedService<RegistratieVerlopenPump>();
var app = builder.Build(); var app = builder.Build();
@@ -55,12 +48,7 @@ app.MapGet("/health", () => "Healthy");
// a location to read the registration's progress (ADR-0009, eventual consistency). // a location to read the registration's progress (ADR-0009, eventual consistency).
app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) => app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) =>
{ {
// Diploma origin defaults to domestic; a foreign (eIDAS) submission passes "Buitenlands" so the var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn), ct);
// workflow's DMN routes it through CBGV-advies (S-13). An unknown value is a bad request.
if (!Enum.TryParse<DiplomaOrigin>(body.DiplomaOrigin, ignoreCase: true, out var origin) && body.DiplomaOrigin is not null)
return Results.BadRequest(new { error = $"Unknown diplomaOrigin '{body.DiplomaOrigin}'. Expected 'Binnenlands' or 'Buitenlands'." });
var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn, origin), ct);
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
}); });
@@ -108,23 +96,6 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound(); return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
}); });
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn))
return Results.BadRequest(new { error = "A bsn is required to provide documents." });
var outcome = await provide.HandleAsync(new ProvideDocumentsCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == ProvideDocumentsOutcome.Accepted ? 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
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind // Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3). // medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
@@ -146,14 +117,12 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
await app.RunAsync(); await app.RunAsync();
public sealed record SubmitRegistrationRequest(string Bsn, string? DiplomaOrigin = null); 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 WithdrawRequest(string Bsn);
public sealed record ProvideDocumentsRequest(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;
@@ -1,37 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>
/// Handles one acquired <c>RegistratieVerlopen</c> external-worker job (S-10a, ADR-0017): load the
/// registration the job correlates to and expire it to VERLOPEN — the 30-day document-wait timer fired
/// before the documents arrived, so the case is cancelled. Pure application logic over ports; it knows
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
/// <see cref="OpenZaakWorker"/>.
/// </summary>
public sealed class ExpireRegistrationWorker(IRegistrationStore store)
{
/// <summary>
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
/// registration is already resolved — a redelivered expiry (VERLOPEN), or one withdrawn/decided
/// while it waited (INGETROKKEN/INGESCHREVEN/AFGEWEZEN) — is a no-op, so the job still completes
/// rather than throwing into a redelivery loop. Only a still-open registration is expired. An
/// unknown registration is an error: it throws, leaving the job un-completed for Flowable to redeliver.
/// </summary>
public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(job);
var registration = await store.GetAsync(job.RegistrationId, ct)
?? throw new InvalidOperationException(
$"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}.");
// Only a still-open registration lapses; an already-resolved one (expired, or withdrawn/decided
// while it waited) is left untouched so the job can complete without violating the aggregate.
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
return;
registration.Expire();
await store.SaveAsync(registration, ct);
}
}
+3 -21
View File
@@ -11,12 +11,10 @@ public interface IWorkflowClient
{ {
/// <summary> /// <summary>
/// Start one <c>registratie</c> process instance for the given registration, carrying the /// Start one <c>registratie</c> process instance for the given registration, carrying the
/// registration id (so the <c>OpenZaakAanmaken</c> external task can be correlated back to its /// registration id so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
/// aggregate) and the diploma origin (so the workflow's DMN can route foreign diplomas through /// aggregate. Returns the process instance id.
/// CBGV-advies, S-13). Returns the process instance id.
/// </summary> /// </summary>
Task<string> StartRegistrationProcessAsync( Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default);
/// <summary> /// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the /// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
@@ -25,14 +23,6 @@ public interface IWorkflowClient
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless. /// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary> /// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default); Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
/// <summary>
/// Signal that the required documents have arrived (S-10a): complete the <c>WachtOpDocumenten</c>
/// user task in the instance so the process leaves the 30-day wait state and continues to
/// beoordeling (ADR-0017). Best-effort — if the instance is not parked at that task (already
/// continued, or timed out) it is a no-op. The upload trigger that calls this is wired in S-10b.
/// </summary>
Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -103,11 +93,3 @@ public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
/// once the 14-day boundary timer fires (ADR-0015). /// once the 14-day boundary timer fires (ADR-0015).
/// </summary> /// </summary>
public sealed record EscalatieJob(string JobId, string ProcessInstanceId); public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
/// <summary>
/// An acquired <c>RegistratieVerlopen</c> job (S-10a): the Flowable job id and the registration id it
/// carries as a process variable. The 30-day boundary timer on <c>WachtOpDocumenten</c> spawns it when
/// the required documents were not supplied in time; expiring the correlated registration to VERLOPEN
/// cancels the case (ADR-0017).
/// </summary>
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
@@ -1,47 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's signal that they have supplied the documents their registration is
/// waiting for ("documenten aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the
/// DigiD token, forwarded by the BFF): only the registration's own bsn may provide its documents.</summary>
public sealed record ProvideDocumentsCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a provide-documents request.</summary>
public enum ProvideDocumentsOutcome
{
/// <summary>The documents were accepted; the process's document wait was completed (if any).</summary>
Accepted,
/// <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 provide-documents use case (S-10a): a zorgprofessional supplies the documents their registration
/// is parked waiting for, completing the WachtOpDocumenten task so the registratie process leaves the
/// 30-day wait and continues to beoordeling (ADR-0017). Owner-scoped by bsn. Completing the wait is
/// best-effort: if the registration never started a process (or already left the wait), the request
/// still stands, mirroring how <see cref="WithdrawRegistration"/> cancels best-effort. The actual file
/// upload and its ZGW storage via the ACL is S-10b; this is the trigger that unblocks the process.
/// </summary>
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow)
{
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct);
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return ProvideDocumentsOutcome.NotFound;
// Complete the document wait (if a process is running) so beoordeling can proceed.
if (registration.ProcessInstanceId is not null)
await workflow.CompleteDocumentWaitAsync(registration.ProcessInstanceId, ct);
return ProvideDocumentsOutcome.Accepted;
}
}
@@ -2,10 +2,8 @@ using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts. The /// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts.</summary>
/// diploma origin defaults to domestic (the DigiD path); a foreign (eIDAS) submission sets it to public sealed record SubmitRegistrationCommand(string Bsn);
/// <see cref="DiplomaOrigin.Buitenlands"/> so the workflow's DMN routes it through CBGV-advies (S-13).</summary>
public sealed record SubmitRegistrationCommand(string Bsn, DiplomaOrigin DiplomaOrigin = DiplomaOrigin.Binnenlands);
/// <summary> /// <summary>
/// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it, /// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it,
@@ -20,14 +18,13 @@ public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient
{ {
ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(command);
var registration = Registration.Submit(command.Bsn, command.DiplomaOrigin); var registration = Registration.Submit(command.Bsn);
// Persist before starting the process so the worker can correlate the OpenZaakAanmaken // Persist before starting the process so the worker can correlate the OpenZaakAanmaken
// job back to an aggregate that already exists (ADR-0009). // job back to an aggregate that already exists (ADR-0009).
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
var processInstanceId = await workflow.StartRegistrationProcessAsync( var processInstanceId = await workflow.StartRegistrationProcessAsync(registration.Id, ct);
registration.Id, registration.DiplomaOrigin, ct);
registration.RecordProcessStarted(processInstanceId); registration.RecordProcessStarted(processInstanceId);
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
@@ -1,17 +0,0 @@
namespace Big.Domain;
/// <summary>
/// Where a zorgprofessional's diploma was issued. It is the input to the diploma-eligibility decision
/// (S-13): a <see cref="Buitenlands"/> (foreign) diploma routes the registratie through an extra
/// CBGV-advies assessment step, a <see cref="Binnenlands"/> (domestic) one goes straight to beoordeling.
/// The decision itself lives in the workflow's DMN, not here (ADR-0016); the domain only carries the
/// origin and hands it to the process as a start variable.
/// </summary>
public enum DiplomaOrigin
{
/// <summary>A Dutch (domestic) diploma. Default for a registration submitted via DigiD.</summary>
Binnenlands,
/// <summary>A foreign diploma (e.g. an eIDAS submission). Triggers the CBGV-advies step.</summary>
Buitenlands,
}
+6 -29
View File
@@ -7,11 +7,10 @@ namespace Big.Domain;
/// </summary> /// </summary>
public sealed class Registration public sealed class Registration
{ {
private Registration(RegistrationId id, string bsn, DiplomaOrigin diplomaOrigin) private Registration(RegistrationId id, string bsn)
{ {
Id = id; Id = id;
Bsn = bsn; Bsn = bsn;
DiplomaOrigin = diplomaOrigin;
Status = RegistrationStatus.Ingediend; Status = RegistrationStatus.Ingediend;
} }
@@ -21,10 +20,6 @@ public sealed class Registration
/// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary> /// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary>
public string Bsn { get; } public string Bsn { get; }
/// <summary>Where the diploma was issued. Rides along to the process as a start variable and
/// drives the diploma-eligibility DMN's foreign→CBGV-advies routing (S-13, ADR-0016).</summary>
public DiplomaOrigin DiplomaOrigin { get; }
public RegistrationStatus Status { get; private set; } public RegistrationStatus Status { get; private set; }
/// <summary>The Flowable process instance driving this registration, once started.</summary> /// <summary>The Flowable process instance driving this registration, once started.</summary>
@@ -33,13 +28,11 @@ public sealed class Registration
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary> /// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
public Uri? ZaakUrl { get; private set; } public Uri? ZaakUrl { get; private set; }
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>. /// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.</summary>
/// The diploma origin defaults to <see cref="DiplomaOrigin.Binnenlands"/> — the common DigiD path; public static Registration Submit(string bsn)
/// a foreign (eIDAS) submission passes <see cref="DiplomaOrigin.Buitenlands"/>.</summary>
public static Registration Submit(string bsn, DiplomaOrigin diplomaOrigin = DiplomaOrigin.Binnenlands)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(bsn); ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
return new Registration(RegistrationId.New(), bsn, diplomaOrigin); return new Registration(RegistrationId.New(), bsn);
} }
/// <summary>Record that the registratie workflow process has been started for this registration.</summary> /// <summary>Record that the registratie workflow process has been started for this registration.</summary>
@@ -133,24 +126,8 @@ public sealed class Registration
Status = RegistrationStatus.Ingetrokken; Status = RegistrationStatus.Ingetrokken;
} }
/// <summary> // A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or
/// Expire the registration — the 30-day document-wait timer fired before the required documents // IN_BEHANDELING).
/// were supplied, so the registratie process cancels the case (S-10a). Allowed while it is still
/// open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a decided (INGESCHREVEN/AFGEWEZEN) or
/// withdrawn (INGETROKKEN) registration can no longer expire. Re-expiring one already
/// <see cref="RegistrationStatus.Verlopen"/> is a no-op — the worker job may be redelivered (§8.6).
/// </summary>
public void Expire()
{
if (Status == RegistrationStatus.Verlopen)
return;
RequireOpenForDecision(nameof(Expire));
Status = RegistrationStatus.Verlopen;
}
// A decision (or withdrawal, or expiry) is only valid while the registration is still open
// (INGEDIEND or IN_BEHANDELING).
private void RequireOpenForDecision(string decision) private void RequireOpenForDecision(string decision)
{ {
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)) if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
@@ -21,8 +21,4 @@ public enum RegistrationStatus
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary> /// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
Ingetrokken, Ingetrokken,
/// <summary>Lapsed: the required documents were not supplied within the 30-day window, so the
/// registratie process cancelled the case (S-10a). Terminal.</summary>
Verlopen,
} }
@@ -15,31 +15,24 @@ 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, IRegistratieVerlopenClient : IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient
{ {
private const string Topic = "OpenZaakAanmaken"; private const string Topic = "OpenZaakAanmaken";
private const string EscalatieTopic = "BeoordelingEscaleren"; private const string EscalatieTopic = "BeoordelingEscaleren";
private const string VerlopenTopic = "RegistratieVerlopen";
private const string ProcessDefinitionKey = "registratie"; private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen"; private const string BeoordelenTaskKey = "Beoordelen";
private const string WachtOpDocumentenTaskKey = "WachtOpDocumenten";
private const string BehandelaarGroup = "behandelaar"; private const string BehandelaarGroup = "behandelaar";
private const string TeamleadGroup = "teamlead"; private const string TeamleadGroup = "teamlead";
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string DiplomaOriginVariable = "diplomaOrigin";
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"; private const string IngetrokkenMessage = "RegistratieIngetrokken";
public async Task<string> StartRegistrationProcessAsync( public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{ {
var request = new StartProcessRequest( var request = new StartProcessRequest(
ProcessDefinitionKey, ProcessDefinitionKey,
[ [new Variable(RegistrationIdVariable, "string", registrationId.ToString())]);
new Variable(RegistrationIdVariable, "string", registrationId.ToString()),
new Variable(DiplomaOriginVariable, "string", diplomaOrigin.ToString()),
]);
var created = await PostAsync<StartProcessRequest, ProcessInstance>( var created = await PostAsync<StartProcessRequest, ProcessInstance>(
"service/runtime/process-instances", request, ct) "service/runtime/process-instances", request, ct)
@@ -116,25 +109,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
{
// Find the still-open WachtOpDocumenten task in this instance and complete it, so the process
// leaves the 30-day wait and continues to beoordeling (S-10a, ADR-0017). If the instance is no
// longer parked there (already continued, or the timer already cancelled it) this is a
// best-effort no-op — mirroring the withdrawal/escalation correlation (§8.6).
var query = new TaskByInstanceQueryRequest(processInstanceId, WachtOpDocumentenTaskKey);
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
"service/query/tasks", query, ct);
var task = page?.Data?.FirstOrDefault();
if (task is null)
return;
using var response = await SendAsync(
$"service/runtime/tasks/{task.Id}", new CompleteTaskRequest("complete", []), ct);
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default) public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
{ {
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId); var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
@@ -176,23 +150,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
{
var request = new AcquireJobsRequest(VerlopenTopic, options.LockDuration, maxJobs, options.WorkerId);
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredJob>>(
"external-job-api/acquire/jobs", request, ct) ?? [];
return [.. jobs.Select(job => new RegistratieVerlopenJob(job.Id, RegistrationId.Parse(job.RegistrationId())))];
}
public async Task CompleteRegistratieVerlopenJobAsync(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) private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{ {
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path)); var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
@@ -36,19 +36,3 @@ public interface IBeoordelingEscalatieClient
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary> /// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default); Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
} }
/// <summary>
/// The document-timeout side of the Workflow Client (S-10a): the <c>RegistratieVerlopen</c>
/// external-worker jobs parked by the 30-day boundary timer on <c>WachtOpDocumenten</c>. Kept separate
/// from the other worker ports (interface segregation) so neither the OpenZaak nor escalation worker
/// sees expiry. Implemented by <see cref="FlowableWorkflowClient"/> — the only code that talks to
/// Flowable (§8.2, ADR-0017).
/// </summary>
public interface IRegistratieVerlopenClient
{
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>RegistratieVerlopen</c> jobs.</summary>
Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default);
/// <summary>Complete an acquired expiry job so its token reaches the <c>endVerlopen</c> end event.</summary>
Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default);
}
@@ -1,41 +0,0 @@
using Big.Application;
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// One poll tick of the document-timeout worker (S-10a, ADR-0017): acquire the parked
/// <c>RegistratieVerlopen</c> jobs — the tokens the 30-day boundary timer on <c>WachtOpDocumenten</c>
/// spawns — expire each correlated registration via the <see cref="ExpireRegistrationWorker"/>, and
/// complete the job so its token reaches <c>endVerlopen</c>. 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→expire→complete logic is unit-testable without a running host. Mirrors
/// <see cref="OpenZaakJobProcessor"/> and <see cref="BeoordelingEscalatieProcessor"/>.
/// </summary>
public sealed class RegistratieVerlopenProcessor(
IRegistratieVerlopenClient client,
ExpireRegistrationWorker worker,
ILogger<RegistratieVerlopenProcessor> logger)
{
/// <summary>Acquire and process up to <paramref name="maxJobs"/> jobs. Returns the number acquired.</summary>
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
{
var jobs = await client.AcquireRegistratieVerlopenJobsAsync(maxJobs, ct);
foreach (var job in jobs)
{
try
{
await worker.HandleAsync(job, ct);
await client.CompleteRegistratieVerlopenJobAsync(job.JobId, ct);
}
catch (Exception ex)
{
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
logger.LogError(ex, "RegistratieVerlopen job {JobId} failed; leaving it for redelivery.", job.JobId);
}
}
return jobs.Count;
}
}
@@ -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 document-timeout worker (S-10a, ADR-0017): on an interval it
/// resolves a scoped <see cref="RegistratieVerlopenProcessor"/> and asks it to drain the parked
/// <c>RegistratieVerlopen</c> jobs. A deliberately thin shell — all acquire/expire/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="BeoordelingEscalatiePump"/>.
/// </summary>
public sealed class RegistratieVerlopenPump(
IServiceScopeFactory scopeFactory,
FlowableOptions options,
ILogger<RegistratieVerlopenPump> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<RegistratieVerlopenProcessor>();
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, "RegistratieVerlopen job poll failed; retrying after the poll interval.");
}
try
{
await Task.Delay(options.PollInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
@@ -1,84 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
// S-10a (#102): the application handler behind the RegistratieVerlopen external-worker job. The 30-day
// document-wait timer fired, so the correlated registration is expired to VERLOPEN. Mirrors
// OpenZaakWorker — pure application logic over ports, idempotent under at-least-once delivery (§8.6).
public class ExpireRegistrationWorkerTests
{
private const string Bsn = "123456782";
private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId);
return registration;
}
[Fact]
public async Task Expires_the_registration_the_job_correlates_to()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
await new ExpireRegistrationWorker(store).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Verlopen, saved!.Status);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task An_already_verlopen_registration_is_not_persisted_again()
{
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again.
var store = new FakeRegistrationStore();
var registration = Submitted();
registration.Expire();
store.Seed(registration);
await new ExpireRegistrationWorker(store).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount);
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
{
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
// into a redelivery loop.
var store = new FakeRegistrationStore();
var registration = Submitted();
registration.Withdraw();
store.Seed(registration);
await new ExpireRegistrationWorker(store).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task An_unknown_registration_throws_so_the_job_is_redelivered()
{
var store = new FakeRegistrationStore();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
new ExpireRegistrationWorker(store).HandleAsync(
new RegistratieVerlopenJob("job-7", RegistrationId.New())));
}
[Fact]
public async Task Rejects_a_null_job()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!));
}
+1 -11
View File
@@ -32,16 +32,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient : IWorkflowClient
{ {
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public DiplomaOrigin? StartedWithOrigin { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; } public string? WithdrawnProcessInstanceId { get; private set; }
public string? CompletedDocumentWaitFor { get; private set; }
public Task<string> StartRegistrationProcessAsync( public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{ {
onStart?.Invoke(registrationId); onStart?.Invoke(registrationId);
StartedFor = registrationId; StartedFor = registrationId;
StartedWithOrigin = diplomaOrigin;
return Task.FromResult(processInstanceId); return Task.FromResult(processInstanceId);
} }
@@ -50,12 +46,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
WithdrawnProcessInstanceId = processInstanceId; WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
{
CompletedDocumentWaitFor = 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
@@ -24,7 +24,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}""")); var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
var rid = RegistrationId.New(); var rid = RegistrationId.New();
var pid = await client.StartRegistrationProcessAsync(rid, DiplomaOrigin.Binnenlands); var pid = await client.StartRegistrationProcessAsync(rid);
Assert.Equal("pi-1", pid); Assert.Equal("pi-1", pid);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
@@ -38,22 +38,6 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{rid}\"", capture.Body); Assert.Contains($"\"value\":\"{rid}\"", capture.Body);
} }
[Theory]
[InlineData(DiplomaOrigin.Binnenlands, "Binnenlands")]
[InlineData(DiplomaOrigin.Buitenlands, "Buitenlands")]
public async Task Start_posts_the_diploma_origin_as_a_process_variable(DiplomaOrigin origin, string expected)
{
// The diploma origin rides along as a start variable so the workflow's DMN can route foreign
// diplomas through CBGV-advies (S-13, ADR-0016).
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
await client.StartRegistrationProcessAsync(RegistrationId.New(), origin);
Assert.Contains("\"name\":\"diplomaOrigin\"", capture.Body);
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
}
[Fact] [Fact]
public async Task Start_uses_the_configured_worker_credentials_and_defaults() public async Task Start_uses_the_configured_worker_credentials_and_defaults()
{ {
@@ -141,7 +125,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>( await Assert.ThrowsAsync<HttpRequestException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands)); () => client.StartRegistrationProcessAsync(RegistrationId.New()));
} }
[Fact] [Fact]
@@ -151,7 +135,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.Created, "null")); var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands)); () => client.StartRegistrationProcessAsync(RegistrationId.New()));
Assert.Contains("empty process-instance", ex.Message); Assert.Contains("empty process-instance", ex.Message);
} }
@@ -426,106 +410,4 @@ public class FlowableWorkflowClientTests
capture.Seen.RequestUri!.ToString()); capture.Seen.RequestUri!.ToString());
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
} }
// ── S-10a (#102): document-wait timeout → RegistratieVerlopen (ADR-0017) ──────────────────────
// A 30-day interrupting boundary timer on WachtOpDocumenten spawns a RegistratieVerlopen
// external-worker job carrying the registration id; the worker expires the registration and
// completes the job. Separately, "documents received" completes the WachtOpDocumenten user task.
[Fact]
public async Task Acquire_verlopen_jobs_posts_the_topic_and_parses_jobs_with_their_registration_id()
{
var rid = RegistrationId.New();
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
$$"""[{"id":"job-9","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]"""));
var jobs = await client.AcquireRegistratieVerlopenJobsAsync(3);
var job = Assert.Single(jobs);
Assert.Equal("job-9", job.JobId);
Assert.Equal(rid, job.RegistrationId);
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
capture.Seen!.RequestUri!.ToString());
Assert.Contains("\"topic\":\"RegistratieVerlopen\"", capture.Body);
Assert.Contains("\"numberOfTasks\":3", capture.Body);
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
}
[Theory]
[InlineData("[]")]
[InlineData("null")]
public async Task Acquire_verlopen_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.AcquireRegistratieVerlopenJobsAsync(1));
Assert.NotNull(capture.Seen);
}
[Fact]
public async Task Complete_verlopen_job_posts_to_the_job_complete_endpoint()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await client.CompleteRegistratieVerlopenJobAsync("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);
}
[Fact]
public async Task Provide_documents_completes_the_wacht_op_documenten_task_in_the_instance()
{
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()));
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
? new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"task-3"}],"total":1}""",
Encoding.UTF8, "application/json"),
}
: new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.CompleteDocumentWaitAsync("pi-1");
// 1. Find the still-open WachtOpDocumenten 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\":\"WachtOpDocumenten\"", query.Body);
// 2. Complete that task so the process leaves the wait state.
var complete = requests.Single(r => r.Url.EndsWith("service/runtime/tasks/task-3"));
Assert.Equal(HttpMethod.Post, complete.Method);
Assert.Contains("\"action\":\"complete\"", complete.Body);
}
[Fact]
public async Task Provide_documents_is_a_no_op_when_the_wait_task_is_no_longer_open()
{
// The process already left WachtOpDocumenten (e.g. timed out): nothing to complete, 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.CompleteDocumentWaitAsync("pi-1");
// Only the query ran; no task-completion POST followed.
Assert.DoesNotContain(methods, m => m == HttpMethod.Put || m == HttpMethod.Delete);
Assert.Single(methods);
}
} }
@@ -1,85 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
// S-10a (#102): the "documents received" use case. A zorgprofessional supplies the documents their
// registration is waiting for; the handler completes the WachtOpDocumenten task via the Workflow Client
// so the process leaves the 30-day wait and continues to beoordeling. Owner-scoped by the caller's bsn,
// like WithdrawRegistration. (The real file upload + ZGW storage is S-10b; this is the trigger path.)
public class ProvideDocumentsTests
{
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 ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
[Fact]
public async Task Providing_documents_completes_the_document_wait()
{
var store = new FakeRegistrationStore();
var registration = Submitted("proc-42");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new ProvideDocuments(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task A_different_bsn_cannot_provide_documents()
{
// Owner-scoping: only the registration's own bsn may supply its documents. Another bsn is told
// NotFound (existence not revealed) and the wait is not completed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new ProvideDocuments(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(ProvideDocumentsOutcome.NotFound, outcome);
Assert.Null(workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task Providing_for_an_unknown_registration_is_not_found()
{
var store = new FakeRegistrationStore();
var handler = new ProvideDocuments(store, new FakeWorkflowClient());
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
}
[Fact]
public async Task Providing_before_a_process_started_is_accepted_without_calling_the_workflow()
{
// No process yet → no wait task to complete; the request still stands (best-effort, mirroring
// WithdrawRegistration) and the Workflow Client is not called.
var store = new FakeRegistrationStore();
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new ProvideDocuments(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
Assert.Null(workflow.CompletedDocumentWaitFor);
}
[Fact]
public async Task Rejects_a_null_command()
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient()).HandleAsync(null!));
}
@@ -1,79 +0,0 @@
using Big.Application;
using Big.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Big.Tests;
// S-10a (#102): the document-timeout drain loop. Mirrors BeoordelingEscalatieProcessor — acquire the
// parked RegistratieVerlopen jobs (the tokens the 30-day boundary timer on WachtOpDocumenten spawns),
// expire each correlated registration via the ExpireRegistrationWorker, then complete the job. A job
// whose expiry fails is logged and left un-completed for Flowable to redeliver (§8.6).
public class RegistratieVerlopenProcessorTests
{
/// <summary>A fake client scripting the jobs to acquire and recording completions.</summary>
private sealed class FakeVerlopenClient(params RegistratieVerlopenJob[] jobs) : IRegistratieVerlopenClient
{
public int AcquireCount { get; private set; }
public List<string> Completed { get; } = [];
public Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
{
AcquireCount++;
return Task.FromResult<IReadOnlyList<RegistratieVerlopenJob>>(jobs.Take(maxJobs).ToList());
}
public Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
{
Completed.Add(jobId);
return Task.CompletedTask;
}
}
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store);
[Fact]
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
{
var store = new FakeRegistrationStore();
var registration = Domain.Registration.Submit("123456782");
store.Seed(registration);
var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", registration.Id));
var acquired = await new RegistratieVerlopenProcessor(
client, Worker(store), NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
Assert.Equal(1, acquired);
Assert.Equal(Domain.RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
Assert.Equal("job-9", Assert.Single(client.Completed));
}
[Fact]
public async Task A_failing_expiry_is_left_uncompleted_for_flowable_to_redeliver()
{
// Unknown registration → the worker throws → the job is left for redelivery, error logged.
var store = new FakeRegistrationStore();
var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", Domain.RegistrationId.New()));
var logger = new CapturingLogger<RegistratieVerlopenProcessor>();
var acquired = await new RegistratieVerlopenProcessor(client, Worker(store), 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_jobs()
{
var client = new FakeVerlopenClient();
var acquired = await new RegistratieVerlopenProcessor(
client, Worker(new FakeRegistrationStore()), NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
Assert.Equal(0, acquired);
Assert.Equal(1, client.AcquireCount);
Assert.Empty(client.Completed);
}
}
@@ -16,15 +16,6 @@ public class RegistrationTests
Assert.Null(registration.ProcessInstanceId); Assert.Null(registration.ProcessInstanceId);
} }
[Fact]
public void A_registration_defaults_to_a_domestic_diploma()
=> Assert.Equal(DiplomaOrigin.Binnenlands, Registration.Submit("123456782").DiplomaOrigin);
[Fact]
public void A_foreign_diploma_submission_records_its_origin()
=> Assert.Equal(DiplomaOrigin.Buitenlands,
Registration.Submit("123456782", DiplomaOrigin.Buitenlands).DiplomaOrigin);
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
@@ -294,63 +285,4 @@ public class RegistrationTests
Assert.Contains("only an INGEDIEND", ex.Message); Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
} }
[Fact]
public void Expiring_an_ingediend_registration_sets_it_verlopen()
{
// The 30-day document-wait timer fired before the documents arrived (S-10a): the case is
// cancelled and the aggregate becomes terminal VERLOPEN.
var registration = Registration.Submit("123456782");
registration.Expire();
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
}
[Fact]
public void Expiring_needs_no_zaak()
{
// The timer fires on a purely time-based boundary; expiry does not depend on the zaak.
var registration = Registration.Submit("123456782");
registration.Expire();
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Re_expiring_an_already_verlopen_registration_is_idempotent()
{
// The RegistratieVerlopen worker job may be redelivered (§8.6); re-expiring is a no-op.
var registration = Registration.Submit("123456782");
registration.Expire();
registration.Expire();
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
}
[Fact]
public void Expiring_an_approved_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Expiring_a_withdrawn_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
} }
@@ -25,19 +25,6 @@ public class SubmitRegistrationTests
Assert.Equal(2, store.SaveCount); Assert.Equal(2, store.SaveCount);
} }
[Fact]
public async Task Submitting_a_foreign_diploma_carries_its_origin_to_the_process()
{
var store = new FakeRegistrationStore();
var workflow = new FakeWorkflowClient();
var handler = new SubmitRegistration(store, workflow);
var id = await handler.HandleAsync(new SubmitRegistrationCommand("123456782", DiplomaOrigin.Buitenlands));
Assert.Equal(DiplomaOrigin.Buitenlands, (await store.GetAsync(id))!.DiplomaOrigin);
Assert.Equal(DiplomaOrigin.Buitenlands, workflow.StartedWithOrigin);
}
[Fact] [Fact]
public async Task Rejects_a_null_command_without_touching_the_store_or_workflow() public async Task Rejects_a_null_command_without_touching_the_store_or_workflow()
{ {
+1 -2
View File
@@ -5,8 +5,7 @@
"reporters": ["progress", "html"], "reporters": ["progress", "html"],
"mutate": [ "mutate": [
"!**/OpenZaakJobPump.cs", "!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs", "!**/BeoordelingEscalatiePump.cs"
"!**/RegistratieVerlopenPump.cs"
], ],
"thresholds": { "thresholds": {
"high": 95, "high": 95,
@@ -1,19 +0,0 @@
# language: en
# Drives S-13 (#14). A registration's diploma origin decides its route: a domestic (Binnenlands)
# diploma goes straight to beoordeling, a foreign (Buitenlands) one is routed through an extra
# CBGV-advies step (PRD flow 4). The decision itself is a DMN evaluated inside the workflow
# (ADR-0016); the domain's part — verified here — is carrying the origin into the process so the DMN
# can route on it. The DMN evaluation and the CBGV routing are verified live (verify-domain).
Feature: Een diploma op herkomst routeren
Als register wil ik een aanvraag met een buitenlands diploma extra laten toetsen
zodat een CBGV-advies wordt ingewonnen voordat een behandelaar beoordeelt.
Scenario: Een binnenlands diploma start de registratie als binnenlands
Given a zorgprofessional with a "Binnenlands" diploma
When they submit their registration
Then the registratie process is started carrying a "Binnenlands" diploma
Scenario: Een buitenlands diploma start de registratie als buitenlands
Given a zorgprofessional with a "Buitenlands" diploma
When they submit their registration
Then the registratie process is started carrying a "Buitenlands" diploma
@@ -1,23 +0,0 @@
# language: en
# Drives S-10a (#102). After the zaak is opened the process parks at WachtOpDocumenten with an
# INTERRUPTING 30-day boundary timer. If the documents do not arrive in time the timer cancels the
# task and parks a RegistratieVerlopen job (ADR-0017) which the timeout worker drains, expiring the
# registration to VERLOPEN. Documents received before the timer fires close the wait, so no expiry
# happens. This scenario exercises the timeout worker against an in-memory Flowable stand-in; the timer
# firing live is verify-domain.
Feature: Een documenttermijn laten verlopen
Als registerbeheerder wil ik dat een aanvraag waarvoor de documenten niet binnen 30 dagen binnen zijn
automatisch vervalt zodat onvolledige aanvragen niet blijven liggen.
Scenario: Zonder documenten binnen 30 dagen vervalt de registratie
Given a registration parked at the WachtOpDocumenten task
When the 30-day document timer fires
And the document-timeout worker runs
Then the registration is verlopen
Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
Given a registration parked at the WachtOpDocumenten task
When the documents arrive before the timer fires
And the 30-day document timer fires
And the document-timeout worker runs
Then the registration is not verlopen
@@ -1,36 +0,0 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenDiplomaRouteren.feature</c> (S-13). Drives the submit use case against
/// in-memory ports and asserts the registratie process is started carrying the diploma origin — the
/// domain's contribution to flow 4. The DMN evaluation and the foreign→CBGV-advies routing it drives
/// are verified live (verify-domain); one instance per scenario.</summary>
[Binding]
[Scope(Feature = "Een diploma op herkomst routeren")]
public sealed class EenDiplomaRouterenSteps
{
private readonly InMemoryRegistrationStore _store = new();
private readonly InMemoryWorkflowClient _workflow = new();
private DiplomaOrigin _origin;
[Given("a zorgprofessional with a \"(.*)\" diploma")]
public void GivenAZorgprofessionalWithADiploma(string origin)
=> _origin = Enum.Parse<DiplomaOrigin>(origin, ignoreCase: true);
[When("they submit their registration")]
public async Task WhenTheySubmitTheirRegistration()
=> await new SubmitRegistration(_store, _workflow).HandleAsync(
new SubmitRegistrationCommand("123456782", _origin));
[Then("the registratie process is started carrying a \"(.*)\" diploma")]
public void ThenTheProcessIsStartedCarryingTheDiploma(string expected)
{
Assert.NotNull(_workflow.StartedFor);
Assert.Equal(Enum.Parse<DiplomaOrigin>(expected, ignoreCase: true), _workflow.StartedWithOrigin);
}
}
@@ -1,52 +0,0 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenDocumentTermijnVerlopen.feature</c> (S-10a). Drives the timeout worker
/// (<see cref="RegistratieVerlopenProcessor"/> over the <see cref="ExpireRegistrationWorker"/>) against
/// an in-memory Flowable stand-in and a shared registration store; one instance per scenario. The
/// interrupting 30-day timer either cancels the wait and expires the registration, or — if the
/// documents arrived first — never fires; the scenario asserts on the aggregate's status.</summary>
[Binding]
[Scope(Feature = "Een documenttermijn laten verlopen")]
public sealed class EenDocumentTermijnVerlopenSteps
{
private readonly InMemoryDocumentTimeoutClient _flowable = new();
private readonly Support.InMemoryRegistrationStore _store = new();
private Registration _registration = null!;
private string _processInstanceId = "";
[Given("a registration parked at the WachtOpDocumenten task")]
public async Task GivenARegistrationParkedAtWachtOpDocumenten()
{
_registration = Registration.Submit("123456782");
await _store.SaveAsync(_registration);
_processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
}
[When("the 30-day document timer fires")]
public void WhenTheDocumentTimerFires() => _flowable.FireDocumentTimer(_processInstanceId);
[When("the documents arrive before the timer fires")]
public void WhenTheDocumentsArriveBeforeTheTimer() => _flowable.ReceiveDocuments(_processInstanceId);
[When("the document-timeout worker runs")]
public async Task WhenTheTimeoutWorkerRuns()
=> await new RegistratieVerlopenProcessor(
_flowable, new ExpireRegistrationWorker(_store),
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
[Then("the registration is verlopen")]
public async Task ThenTheRegistrationIsVerlopen()
=> Assert.Equal(RegistrationStatus.Verlopen, (await _store.GetAsync(_registration.Id))!.Status);
[Then("the registration is not verlopen")]
public async Task ThenTheRegistrationIsNotVerlopen()
=> Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
}
@@ -72,9 +72,6 @@ public sealed class CapturingDomainClient : IDomainClient
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true); => Task.FromResult(true);
public Task<bool> ProvideDocumentsAsync(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>>([]);
@@ -12,15 +12,11 @@ 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 DiplomaOrigin? StartedWithOrigin { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; } public string? WithdrawnProcessInstanceId { get; private set; }
public string? CompletedDocumentWaitFor { get; private set; }
public Task<string> StartRegistrationProcessAsync( public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{ {
StartedFor = registrationId; StartedFor = registrationId;
StartedWithOrigin = diplomaOrigin;
return Task.FromResult(StartedProcessInstanceId); return Task.FromResult(StartedProcessInstanceId);
} }
@@ -29,12 +25,6 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
WithdrawnProcessInstanceId = processInstanceId; WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
{
CompletedDocumentWaitFor = 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,
@@ -137,57 +127,6 @@ public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
} }
} }
/// <summary>An in-memory Flowable stand-in for the document-timeout scenario (S-10a): it models one
/// WachtOpDocumenten wait per process instance — whether it is still open and the registration it
/// correlates to — and the RegistratieVerlopen jobs the interrupting 30-day boundary timer parks. It
/// drives the timeout worker's behaviour without a running Flowable; the timer firing live is the
/// verify-domain check.</summary>
public sealed class InMemoryDocumentTimeoutClient : IRegistratieVerlopenClient
{
private sealed class Wait
{
public required RegistrationId RegistrationId { get; init; }
public bool IsWaiting { get; set; } = true;
}
private readonly Dictionary<string, Wait> _waits = [];
private readonly List<RegistratieVerlopenJob> _parked = [];
private int _seq;
/// <summary>A registration parks at WachtOpDocumenten, waiting for the citizen's documents.</summary>
public string ParkWaitingForDocuments(RegistrationId registrationId)
{
var pid = $"pi-{++_seq}";
_waits[pid] = new Wait { RegistrationId = registrationId };
return pid;
}
/// <summary>The documents arrive before the timer fires: the wait task closes, so the interrupting
/// timer no longer fires (mirrors the Workflow Client completing WachtOpDocumenten).</summary>
public void ReceiveDocuments(string processInstanceId) => _waits[processInstanceId].IsWaiting = false;
/// <summary>The 30-day interrupting boundary timer fires: if still waiting, it cancels the wait and
/// parks a RegistratieVerlopen job carrying the correlated registration id. A no-op if the documents
/// already arrived (the wait/timer race, §8.6).</summary>
public void FireDocumentTimer(string processInstanceId)
{
var wait = _waits[processInstanceId];
if (!wait.IsWaiting)
return;
wait.IsWaiting = false;
_parked.Add(new RegistratieVerlopenJob($"job-{++_seq}", wait.RegistrationId));
}
public Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<RegistratieVerlopenJob>>(_parked.Take(maxJobs).ToList());
public Task CompleteRegistratieVerlopenJobAsync(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
{ {
+30 -45
View File
@@ -1,15 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via // Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the // DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
// openbaar register as INGEDIEND; the citizen supplies the documents the process is waiting for // register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
// (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in the werkbak, // the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
// and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and flows via the // flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
// ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN. test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
page,
context,
}) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login. // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/'); await page.goto('/');
@@ -30,53 +26,42 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
expect(reference, 'the confirmation shows a registration reference').toBeTruthy(); expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection // The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
// catches up. We check it on a SEPARATE page so the self-service tab keeps its (in-memory) submitted // catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
// state — the "Documenten aanleveren" action below acts on that same session. The projection updates // on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
// asynchronously (NRC → event-subscriber), so reload until *this* submission's row appears. We poll // generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
// on the reference cell (not a generic INGEDIEND cell): the shared verify stack already holds // so a status-only poll would short-circuit on a stale row before our row is projected.
// INGEDIEND rows from earlier checks, so a status-only poll would short-circuit on a stale row. await page.goto('http://openbaar/');
const staff = await context.newPage(); await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
await staff.goto('http://openbaar/');
await expect(staff.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
// #78: the reference shown in the public register must be the exact one the citizen saw on the // #78: the reference shown in the public register must be the exact one the citizen saw on the
// submit confirmation — no mismatch between the two portals. // submit confirmation — no mismatch between the two portals.
await expect await expect
.poll(async () => { .poll(async () => {
await staff.reload(); await page.reload();
return staff.getByRole('cell', { name: reference }).count(); return page.getByRole('cell', { name: reference }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0); .toBeGreaterThan(0);
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' })) await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible(); .toBeVisible();
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab. // A behandelaar picks the registration up in the behandel-portal werkbak and approves it
// The process parks at WachtOpDocumenten only after the zaak is opened; the INGEDIEND row above proves // (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
// the zaak exists — so the OpenZaak worker has completed and the process is now at the wait — which is // to the medewerker realm (a different Keycloak realm than the citizen's digid session).
// why we supply the documents here rather than right after submit, when the trigger would race the await page.goto('http://behandel/');
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks await page.locator('#username').fill('merel-behandelaar');
// beoordeling.) await page.locator('#password').fill('test123');
await page.getByRole('button', { name: /documenten aanleveren/i }).click(); await page.locator('#kc-login').click();
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it (goedkeuren) await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
// medewerker realm (a different Keycloak realm than the citizen's digid session).
await staff.goto('http://behandel/');
await staff.locator('#username').fill('merel-behandelaar');
await staff.locator('#password').fill('test123');
await staff.locator('#kc-login').click();
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible(); // The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
// The registration reaches the Beoordelen user task only after its documents are provided (above), so
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the // it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open // decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
// tasks, so a positional match could act on someone else's registration. // tasks, so a positional match could act on someone else's registration.
const goedkeuren = staff.getByRole('button', { name: `Goedkeuren ${reference}` }); const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
await expect await expect
.poll(async () => { .poll(async () => {
await staff.reload(); await page.reload();
return goedkeuren.count(); return goedkeuren.count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0); .toBeGreaterThan(0);
@@ -84,7 +69,7 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only // Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and // dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
// the decision never reaches the domain — so the registration would stay INGEDIEND. // the decision never reaches the domain — so the registration would stay INGEDIEND.
const decided = staff.waitForResponse( const decided = page.waitForResponse(
(r) => (r) =>
r.url().includes(`/behandel/registrations/${reference}/decide`) && r.url().includes(`/behandel/registrations/${reference}/decide`) &&
r.request().method() === 'POST', r.request().method() === 'POST',
@@ -94,11 +79,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
// The approval flows back to the projection; back on the openbaar register *our* row (matched by // The approval flows back to the projection; back on the openbaar register *our* row (matched by
// its reference) now shows INGESCHREVEN. // its reference) now shows INGESCHREVEN.
await staff.goto('http://openbaar/'); await page.goto('http://openbaar/');
await expect await expect
.poll(async () => { .poll(async () => {
await staff.reload(); await page.reload();
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count(); return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0); .toBeGreaterThan(0);
}); });
-41
View File
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
xmlns:flowable="http://flowable.org/dmn"
id="diplomaEligibilityDefinitions"
name="Diploma eligibility"
namespace="http://respellion.nl/big/dmn">
<!-- Diploma-eligibility decision (S-13, ADR-0016). Evaluated inline by the registratie process as a
BPMN DMN service task: given the diploma's origin, it sets the `route` the process should take.
A foreign (Buitenlands) diploma routes through the extra CBGV-advies assessment step; a domestic
one (or anything else) goes DIRECT to beoordeling. FIRST hit policy: the foreign rule wins, and
the empty-input catch-all is the default.
NB: the comment lives INSIDE <definitions> on purpose — Flowable's DMN XML converter chokes on a
comment between the XML declaration and the root element ("XMLStreamReader not in START_DOCUMENT
or START_ELEMENT state"), unlike its BPMN converter. -->
<decision id="diploma-eligibility" name="Diploma eligibility">
<decisionTable id="dt-diploma-eligibility" hitPolicy="FIRST">
<input id="in-origin" label="Diploma origin">
<inputExpression id="ie-origin" typeRef="string">
<text>diplomaOrigin</text>
</inputExpression>
</input>
<output id="out-route" label="Route" name="route" typeRef="string"/>
<rule id="rule-foreign">
<inputEntry id="rule-foreign-in">
<text>"Buitenlands"</text>
</inputEntry>
<outputEntry id="rule-foreign-out">
<text>"CBGV_ADVIES"</text>
</outputEntry>
</rule>
<rule id="rule-default">
<inputEntry id="rule-default-in">
<text></text>
</inputEntry>
<outputEntry id="rule-default-out">
<text>"DIRECT"</text>
</outputEntry>
</rule>
</decisionTable>
</decision>
</definitions>
+32 -148
View File
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:flowable="http://flowable.org/bpmn" xmlns:flowable="http://flowable.org/bpmn"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
@@ -21,17 +20,7 @@
has not picked the task up within 14 days it fires a parallel token to an external-worker task 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 (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 behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
only changes who may claim it. only changes who may claim it. -->
S-13 adds diploma-eligibility routing: between the document wait and Beoordelen a DMN service
task (flowable:type="dmn") evaluates the `diploma-eligibility` decision on the diplomaOrigin
start variable; an exclusive gateway routes a foreign diploma through the CBGV-advies user task
before Beoordelen, a domestic one straight there (ADR-0016).
S-10a adds the document wait: right after the zaak is opened the process parks at a
WachtOpDocumenten user task with an INTERRUPTING P30D boundary timer. "Documents received"
(the S-10b upload, via the Workflow Client) completes the task and the process continues to the
diploma routing; if the 30 days lapse first the timer cancels the task and runs the
RegistratieVerlopen external-worker task, whose worker expires the registration to VERLOPEN,
ending the process as "verlopen" (ADR-0017). -->
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/> <message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
<process id="registratie" name="Registratie ontvangen" isExecutable="true"> <process id="registratie" name="Registratie ontvangen" isExecutable="true">
@@ -44,62 +33,7 @@
flowable:type="external-worker" flowable:type="external-worker"
flowable:topic="OpenZaakAanmaken"/> flowable:topic="OpenZaakAanmaken"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="WachtOpDocumenten"/> <sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
<!-- S-10a: wait for the citizen's documents (diploma). The process parks here; "documents received"
(the S-10b upload path) completes the task via the Workflow Client. An INTERRUPTING P30D
boundary timer cancels the case if the documents never arrive (ADR-0017). -->
<userTask id="WachtOpDocumenten" name="Wacht op documenten"/>
<sequenceFlow id="flow2doc" sourceRef="WachtOpDocumenten" targetRef="DiplomaEligibiliteit"/>
<boundaryEvent id="DocumentenTimer" attachedToRef="WachtOpDocumenten" cancelActivity="true">
<timerEventDefinition>
<timeDuration>P30D</timeDuration>
</timerEventDefinition>
</boundaryEvent>
<sequenceFlow id="flow7" sourceRef="DocumentenTimer" targetRef="RegistratieVerlopen"/>
<!-- On timeout: an external-worker task the Workflow Client picks up to expire the registration to
VERLOPEN (S-10a). Its topic mirrors OpenZaakAanmaken/BeoordelingEscaleren. -->
<serviceTask id="RegistratieVerlopen" name="Registratie laten verlopen"
flowable:type="external-worker"
flowable:topic="RegistratieVerlopen"/>
<sequenceFlow id="flow8" sourceRef="RegistratieVerlopen" targetRef="endVerlopen"/>
<endEvent id="endVerlopen" name="Registratie verlopen"/>
<!-- S-13: evaluate the diploma-eligibility DMN inline (ADR-0016). A Flowable DMN service task
(flowable:type="dmn" — NOT a businessRuleTask, whose default implementation is the legacy
Drools/KIE one that flowable-rest does not bundle) runs the deployed `diploma-eligibility`
decision against the diplomaOrigin start variable and sets the `route` output as a process
variable. The gateway then routes a foreign diploma through CBGV-advies, a domestic one
straight to Beoordelen. -->
<serviceTask id="DiplomaEligibiliteit" name="Diploma-eligibiliteit bepalen" flowable:type="dmn">
<extensionElements>
<flowable:field name="decisionTableReferenceKey">
<flowable:string><![CDATA[diploma-eligibility]]></flowable:string>
</flowable:field>
</extensionElements>
</serviceTask>
<sequenceFlow id="flow2a" sourceRef="DiplomaEligibiliteit" targetRef="RouteOpDiploma"/>
<exclusiveGateway id="RouteOpDiploma" name="Buitenlands diploma?" default="flowDirect"/>
<sequenceFlow id="flowCbgv" sourceRef="RouteOpDiploma" targetRef="CBGVAdvies">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${route == 'CBGV_ADVIES'}]]></conditionExpression>
</sequenceFlow>
<!-- The extra CBGV-style assessment for foreign diplomas (PRD flow 4). A CBGV medewerker
completes it, after which the case continues to the regular beoordeling. -->
<userTask id="CBGVAdvies" name="CBGV-advies" flowable:candidateGroups="cbgv"/>
<sequenceFlow id="flowCbgvNaarBeoordelen" sourceRef="CBGVAdvies" targetRef="Beoordelen"/>
<sequenceFlow id="flowDirect" sourceRef="RouteOpDiploma" targetRef="Beoordelen"/>
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/> <userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
@@ -143,103 +77,53 @@
<omgdc:Bounds x="100" y="100" width="30" height="30"/> <omgdc:Bounds x="100" y="100" width="30" height="30"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken"> <bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
<omgdc:Bounds x="180" y="85" width="120" height="60"/> <omgdc:Bounds x="200" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_wacht" bpmnElement="WachtOpDocumenten">
<omgdc:Bounds x="340" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_documentenTimer" bpmnElement="DocumentenTimer">
<omgdc:Bounds x="385" y="130" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_verlopen" bpmnElement="RegistratieVerlopen">
<omgdc:Bounds x="340" y="220" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endVerlopen" bpmnElement="endVerlopen">
<omgdc:Bounds x="510" y="235" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_dmn" bpmnElement="DiplomaEligibiliteit">
<omgdc:Bounds x="510" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_route" bpmnElement="RouteOpDiploma" isMarkerVisible="true">
<omgdc:Bounds x="680" y="90" width="40" height="40"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_cbgv" bpmnElement="CBGVAdvies">
<omgdc:Bounds x="660" y="200" width="120" height="60"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen"> <bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
<omgdc:Bounds x="790" y="85" width="120" height="60"/> <omgdc:Bounds x="390" y="85" width="120" height="60"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_end" bpmnElement="end"> <bpmndi:BPMNShape id="s_end" bpmnElement="end">
<omgdc:Bounds x="970" y="100" width="30" height="30"/> <omgdc:Bounds x="580" y="100" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
<omgdc:Bounds x="850" y="135" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
<omgdc:Bounds x="850" y="250" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
<omgdc:Bounds x="860" y="70" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
<omgdc:Bounds x="960" y="20" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
<omgdc:Bounds x="1130" y="35" width="30" height="30"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1"> <bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
<omgdi:waypoint x="130" y="115"/> <omgdi:waypoint x="130" y="115"/>
<omgdi:waypoint x="180" y="115"/> <omgdi:waypoint x="200" y="115"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2"> <bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
<omgdi:waypoint x="300" y="115"/> <omgdi:waypoint x="320" y="115"/>
<omgdi:waypoint x="340" y="115"/> <omgdi:waypoint x="390" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2doc" bpmnElement="flow2doc">
<omgdi:waypoint x="460" y="115"/>
<omgdi:waypoint x="510" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow7" bpmnElement="flow7">
<omgdi:waypoint x="400" y="160"/>
<omgdi:waypoint x="400" y="220"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow8" bpmnElement="flow8">
<omgdi:waypoint x="460" y="250"/>
<omgdi:waypoint x="510" y="250"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2a" bpmnElement="flow2a">
<omgdi:waypoint x="630" y="115"/>
<omgdi:waypoint x="680" y="110"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flowDirect" bpmnElement="flowDirect">
<omgdi:waypoint x="720" y="110"/>
<omgdi:waypoint x="790" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flowCbgv" bpmnElement="flowCbgv">
<omgdi:waypoint x="700" y="130"/>
<omgdi:waypoint x="700" y="200"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flowCbgvNaarBeoordelen" bpmnElement="flowCbgvNaarBeoordelen">
<omgdi:waypoint x="780" y="230"/>
<omgdi:waypoint x="820" y="230"/>
<omgdi:waypoint x="820" y="145"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3"> <bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
<omgdi:waypoint x="910" y="115"/> <omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="970" 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"> <bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
<omgdi:waypoint x="865" y="165"/> <omgdi:waypoint x="450" y="165"/>
<omgdi:waypoint x="865" y="250"/> <omgdi:waypoint x="450" y="220"/>
</bpmndi:BPMNEdge> </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"> <bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
<omgdi:waypoint x="875" y="70"/> <omgdi:waypoint x="495" y="70"/>
<omgdi:waypoint x="875" y="50"/> <omgdi:waypoint x="495" y="50"/>
<omgdi:waypoint x="960" y="50"/> <omgdi:waypoint x="580" y="50"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6"> <bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
<omgdi:waypoint x="1080" y="50"/> <omgdi:waypoint x="700" y="50"/>
<omgdi:waypoint x="1130" y="50"/> <omgdi:waypoint x="760" y="50"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>