Compare commits
14 Commits
fix/91-loc
...
feat/15-be
| Author | SHA1 | Date | |
|---|---|---|---|
| eeb2a9cb40 | |||
| b17c2c51b1 | |||
| cb8568c36a | |||
| 6457aad6db | |||
| f02d2ef568 | |||
| aa6132a6a9 | |||
| 33160fa7d0 | |||
| e2eaa0786d | |||
| 8e7a0c86bb | |||
| 0b31f8db49 | |||
| 8a537edd6c | |||
| e7bed37cda | |||
| 94699f3603 | |||
| 951bdd8364 |
@@ -19,5 +19,9 @@ COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
|
|||||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -17,5 +17,9 @@ FROM nginx:1.27-alpine AS runtime
|
|||||||
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
||||||
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
17
apps/portal-nginx-resolver.sh
Normal file
17
apps/portal-nginx-resolver.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||||
|
#
|
||||||
|
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||||
|
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||||
|
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||||
|
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||||
|
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||||
|
#
|
||||||
|
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||||
|
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||||
|
# entrypoint executes or sources it.
|
||||||
|
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||||
|
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||||
|
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||||
|
echo "portal-nginx-resolver: set resolver to $ns"
|
||||||
|
fi
|
||||||
@@ -19,5 +19,9 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
|
|||||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
||||||
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -3,9 +3,29 @@
|
|||||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
@if (submitted()) {
|
@if (submitted()) {
|
||||||
|
@if (withdrawn()) {
|
||||||
|
<p utrecht-paragraph role="status">
|
||||||
|
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
<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 (withdrawFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="withdrawing()"
|
||||||
|
(click)="withdraw()"
|
||||||
|
>
|
||||||
|
Trek aanvraag in
|
||||||
|
</button>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
@if (failed()) {
|
@if (failed()) {
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ class FakeAuth extends AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
function providers(
|
||||||
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
|
withdraw,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: {
|
||||||
|
postSelfServiceRegistrations: post,
|
||||||
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +66,36 @@ describe('RegistrationPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||||
|
const { withdraw, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||||
|
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
|
|||||||
/**
|
/**
|
||||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||||
|
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
@@ -22,6 +23,9 @@ export class RegistrationPage {
|
|||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
protected readonly failed = signal(false);
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly withdrawing = signal(false);
|
||||||
|
protected readonly withdrawn = signal(false);
|
||||||
|
protected readonly withdrawFailed = signal(false);
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
@@ -39,4 +43,24 @@ export class RegistrationPage {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.withdrawing.set(true);
|
||||||
|
this.withdrawFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.withdrawn.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.withdrawFailed.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
docs/architecture/adr-0015-beoordeling-escalation.md
Normal file
77
docs/architecture/adr-0015-beoordeling-escalation.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-17
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
|
||||||
|
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
|
||||||
|
pattern on `Beoordelen`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
|
||||||
|
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
|
||||||
|
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
|
||||||
|
|
||||||
|
Two forces shape this.
|
||||||
|
|
||||||
|
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
|
||||||
|
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
|
||||||
|
escalation happens alongside it.
|
||||||
|
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
|
||||||
|
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
|
||||||
|
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
|
||||||
|
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
|
||||||
|
(§8.2)" posture since ADR-0009.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
|
||||||
|
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
|
||||||
|
behandelaar group to teamlead.**
|
||||||
|
|
||||||
|
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
|
||||||
|
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
|
||||||
|
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
|
||||||
|
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
|
||||||
|
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
|
||||||
|
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
|
||||||
|
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
|
||||||
|
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
|
||||||
|
belongs to the teamlead; its history and variables are untouched.
|
||||||
|
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
|
||||||
|
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
|
||||||
|
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
|
||||||
|
`OpenZaakAanmaken` worker.
|
||||||
|
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
|
||||||
|
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
|
||||||
|
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
|
||||||
|
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
|
||||||
|
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
|
||||||
|
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
|
||||||
|
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
|
||||||
|
timer.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
|
||||||
|
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
|
||||||
|
on redelivery.
|
||||||
|
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
|
||||||
|
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
|
||||||
|
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
|
||||||
|
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
|
||||||
|
surface to the engine for no capability the external-worker route lacks.
|
||||||
|
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
|
||||||
|
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
|
||||||
|
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
|
||||||
@@ -275,3 +275,71 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
|
|||||||
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||||
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||||
> drives the behandel portal in place of the old admin endpoint.
|
> drives the behandel portal in place of the old admin endpoint.
|
||||||
|
|
||||||
|
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||||
|
|
||||||
|
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||||
|
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||||
|
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||||
|
behandelaar's werkbak.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||||
|
# note the "Referentie" on the confirmation.
|
||||||
|
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||||
|
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||||
|
# appears — its Beoordelen task was cancelled.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||||
|
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||||
|
interrupting boundary event ends it → the werkbak drops the case.
|
||||||
|
|
||||||
|
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||||
|
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||||
|
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||||
|
|
||||||
|
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
|
||||||
|
|
||||||
|
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
|
||||||
|
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
|
||||||
|
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
|
||||||
|
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
|
||||||
|
its identity throughout — only who may claim it changes.
|
||||||
|
|
||||||
|
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
|
||||||
|
verify-domain check automates):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
|
||||||
|
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
|
||||||
|
# but NOT claimed.
|
||||||
|
#
|
||||||
|
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
|
||||||
|
FL=http://localhost:8090/flowable-rest/service
|
||||||
|
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||||
|
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||||
|
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"execute"}'
|
||||||
|
#
|
||||||
|
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
|
||||||
|
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren`
|
||||||
|
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
|
||||||
|
candidate group behandelaar → teamlead (§8.2).
|
||||||
|
|
||||||
|
> Both branches (escalate after 14 days; no-op when completed in time) are covered by the
|
||||||
|
> `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing
|
||||||
|
> and reassignment are asserted live by the verify-domain check.
|
||||||
|
|||||||
@@ -151,3 +151,19 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
|
|||||||
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||||
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||||
path.
|
path.
|
||||||
|
|
||||||
|
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||||
|
|
||||||
|
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||||
|
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||||
|
|
||||||
|
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||||
|
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||||
|
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||||
|
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||||
|
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||||
|
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||||
|
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||||
|
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||||
|
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||||
|
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||||
|
|||||||
@@ -17,7 +17,12 @@
|
|||||||
#
|
#
|
||||||
# Port map (host):
|
# Port map (host):
|
||||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||||
|
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
|
||||||
|
#
|
||||||
|
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
|
||||||
|
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
|
||||||
|
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -205,6 +210,12 @@ services:
|
|||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
KC_HTTP_ENABLED: "true"
|
KC_HTTP_ENABLED: "true"
|
||||||
|
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
|
||||||
|
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
|
||||||
|
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
|
||||||
|
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
|
||||||
|
KC_HOSTNAME: http://localhost:8180
|
||||||
|
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||||
ports:
|
ports:
|
||||||
- "8180:8080"
|
- "8180:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -295,6 +306,14 @@ services:
|
|||||||
context: ../services/bff
|
context: ../services/bff
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/bff:dev
|
image: register-referentie/bff:dev
|
||||||
|
environment:
|
||||||
|
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
|
||||||
|
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
|
||||||
|
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
|
||||||
|
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||||
|
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||||
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -303,6 +322,39 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
domain:
|
||||||
|
condition: service_healthy
|
||||||
|
projection-api:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
|
||||||
|
domain:
|
||||||
|
build:
|
||||||
|
context: ../services/domain
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: register-referentie/domain:dev
|
||||||
|
environment:
|
||||||
|
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||||
|
Flowable__Username: rest-admin
|
||||||
|
Flowable__Password: test
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
|
ports:
|
||||||
|
- "8130:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
|
flowable-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||||
@@ -328,6 +380,10 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||||
|
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
|
||||||
|
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
|
||||||
|
# canonical compose).
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
ports:
|
ports:
|
||||||
- "8110:8080"
|
- "8110:8080"
|
||||||
@@ -340,6 +396,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
@@ -362,6 +420,73 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
|
||||||
|
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||||
|
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
|
||||||
|
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
|
||||||
|
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
|
||||||
|
self-service:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/self-service/Dockerfile
|
||||||
|
image: register-referentie/self-service:dev
|
||||||
|
ports:
|
||||||
|
- "8140:80"
|
||||||
|
volumes:
|
||||||
|
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
openbaar:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/openbaar/Dockerfile
|
||||||
|
image: register-referentie/openbaar:dev
|
||||||
|
ports:
|
||||||
|
- "8141:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
|
behandel:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/behandel/Dockerfile
|
||||||
|
image: register-referentie/behandel:dev
|
||||||
|
ports:
|
||||||
|
- "8142:80"
|
||||||
|
volumes:
|
||||||
|
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
bff:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
|
|||||||
3
infra/local-config/behandel.config.json
Normal file
3
infra/local-config/behandel.config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
3
infra/local-config/self-service.config.json
Normal file
3
infra/local-config/self-service.config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/digid"
|
||||||
|
}
|
||||||
@@ -157,4 +157,82 @@ for _ in $(seq 1 15); do
|
|||||||
done
|
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-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer
|
||||||
|
# early via Flowable's management API (the timer job is moved to executable and run), which routes a
|
||||||
|
# parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires
|
||||||
|
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
|
||||||
|
echo ">> submitting a third registration to escalate"
|
||||||
|
loc3="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
|
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_id3="${loc3##*/}"
|
||||||
|
echo ">> third registration $reg_id3"
|
||||||
|
|
||||||
|
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
|
||||||
|
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
rid=os.environ['REG_ID']
|
||||||
|
t=next((t for t in (d.get('data') or [])
|
||||||
|
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
|
||||||
|
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
|
||||||
|
|
||||||
|
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
|
||||||
|
candidate_groups() { python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
links=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
links=[]
|
||||||
|
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
|
||||||
|
|
||||||
|
# The first job id in a management jobs/timer-jobs response on stdin.
|
||||||
|
first_job_id() { python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its Beoordelen task"
|
||||||
|
task_id3=""; pid3=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
|
||||||
|
[ -n "$task_id3" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
|
||||||
|
|
||||||
|
echo ">> asserting the task starts out claimable by the behandelaar group"
|
||||||
|
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
|
||||||
|
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ">> firing the 14-day boundary timer early via the management API"
|
||||||
|
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
|
||||||
|
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
|
||||||
|
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
|
||||||
|
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
|
||||||
|
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
|
||||||
|
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||||
|
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
|
||||||
|
if [ -n "$async_id" ]; then
|
||||||
|
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
|
||||||
|
|
||||||
|
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
|
||||||
|
escalated=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
|
||||||
|
[ "$groups" = "teamlead" ] && { escalated=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ builder.Services.AddHttpClient<FlowableWorkflowClient>();
|
|||||||
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
|
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||||
|
|
||||||
builder.Services.AddScoped<SubmitRegistration>();
|
builder.Services.AddScoped<SubmitRegistration>();
|
||||||
@@ -30,9 +31,13 @@ builder.Services.AddScoped<WithdrawRegistration>();
|
|||||||
builder.Services.AddScoped<Werkbak>();
|
builder.Services.AddScoped<Werkbak>();
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
|
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
|
||||||
|
|
||||||
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
||||||
builder.Services.AddHostedService<OpenZaakJobPump>();
|
builder.Services.AddHostedService<OpenZaakJobPump>();
|
||||||
|
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
|
||||||
|
// each overdue beoordeling to the teamlead (S-14).
|
||||||
|
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -86,3 +86,10 @@ public interface IRegistrationStore
|
|||||||
/// it) and the registration id it carries as a process variable.
|
/// it) and the registration id it carries as a process variable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An acquired <c>BeoordelingEscaleren</c> escalation job (S-14): the Flowable job id and the process
|
||||||
|
/// instance whose still-open <c>Beoordelen</c> task must be reassigned from behandelaar to teamlead
|
||||||
|
/// once the 14-day boundary timer fires (ADR-0015).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Big.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One poll tick of the beoordeling-escalation worker (S-14, ADR-0015): acquire the parked
|
||||||
|
/// <c>BeoordelingEscaleren</c> jobs — the tokens the 14-day boundary timer on <c>Beoordelen</c> spawns
|
||||||
|
/// — reassign each instance's still-open <c>Beoordelen</c> task to the teamlead, and complete the job.
|
||||||
|
/// A job that fails is logged and left un-completed so Flowable redelivers it (§8.6). Split out from
|
||||||
|
/// the hosted pump so the acquire→reassign→complete logic is unit-testable without a running host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BeoordelingEscalatieProcessor(
|
||||||
|
IBeoordelingEscalatieClient client,
|
||||||
|
ILogger<BeoordelingEscalatieProcessor> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and process up to <paramref name="maxJobs"/> escalations. Returns the number acquired.</summary>
|
||||||
|
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(maxJobs, ct);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync(job.ProcessInstanceId, ct);
|
||||||
|
await client.CompleteBeoordelingEscalatieJobAsync(job.JobId, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
|
||||||
|
logger.LogError(ex, "BeoordelingEscaleren job {JobId} failed; leaving it for redelivery.", job.JobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Big.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hosted polling loop of the beoordeling-escalation worker (S-14, ADR-0015): on an interval it
|
||||||
|
/// resolves a scoped <see cref="BeoordelingEscalatieProcessor"/> and asks it to drain the parked
|
||||||
|
/// <c>BeoordelingEscaleren</c> jobs. A deliberately thin shell — all acquire/reassign/complete logic
|
||||||
|
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
|
||||||
|
/// and loop resilience. Structurally identical to <see cref="OpenZaakJobPump"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BeoordelingEscalatiePump(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
FlowableOptions options,
|
||||||
|
ILogger<BeoordelingEscalatiePump> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var processor = scope.ServiceProvider.GetRequiredService<BeoordelingEscalatieProcessor>();
|
||||||
|
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
|
||||||
|
logger.LogError(ex, "BeoordelingEscaleren job poll failed; retrying after the poll interval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(options.PollInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,14 @@ namespace Big.Infrastructure;
|
|||||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
|
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient
|
||||||
{
|
{
|
||||||
private const string Topic = "OpenZaakAanmaken";
|
private const string Topic = "OpenZaakAanmaken";
|
||||||
|
private const string EscalatieTopic = "BeoordelingEscaleren";
|
||||||
private const string ProcessDefinitionKey = "registratie";
|
private const string ProcessDefinitionKey = "registratie";
|
||||||
private const string BeoordelenTaskKey = "Beoordelen";
|
private const string BeoordelenTaskKey = "Beoordelen";
|
||||||
|
private const string BehandelaarGroup = "behandelaar";
|
||||||
|
private const string TeamleadGroup = "teamlead";
|
||||||
private const string RegistrationIdVariable = "registrationId";
|
private const string RegistrationIdVariable = "registrationId";
|
||||||
private const string ZaakUrlVariable = "zaakUrl";
|
private const string ZaakUrlVariable = "zaakUrl";
|
||||||
private const string BesluitVariable = "besluit";
|
private const string BesluitVariable = "besluit";
|
||||||
@@ -106,6 +109,47 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||||
|
|
||||||
|
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredEscalatieJob>>(
|
||||||
|
"external-job-api/acquire/jobs", request, ct) ?? [];
|
||||||
|
|
||||||
|
return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting
|
||||||
|
// boundary timer); find that task in this instance so we can move it to the teamlead. If the
|
||||||
|
// behandelaar completed it just before the timer fired there is nothing to reassign — a
|
||||||
|
// best-effort no-op (the timer/completion race, cf. §8.6).
|
||||||
|
var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey);
|
||||||
|
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
|
||||||
|
"service/query/tasks", query, ct);
|
||||||
|
|
||||||
|
var task = page?.Data?.FirstOrDefault();
|
||||||
|
if (task is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Add teamlead, then drop behandelaar: the task now belongs to the teamlead group.
|
||||||
|
using (var added = await SendAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}/identitylinks",
|
||||||
|
new IdentityLinkRequest(TeamleadGroup, "candidate"), ct))
|
||||||
|
added.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await DeleteAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(
|
||||||
|
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
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));
|
||||||
@@ -115,6 +159,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path));
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(path, body, ct);
|
using var response = await SendAsync(path, body, ct);
|
||||||
@@ -154,6 +206,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
||||||
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
||||||
|
|
||||||
|
private sealed record TaskByInstanceQueryRequest(
|
||||||
|
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId,
|
||||||
|
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey);
|
||||||
|
|
||||||
|
private sealed record IdentityLinkRequest(
|
||||||
|
[property: JsonPropertyName("group")] string Group,
|
||||||
|
[property: JsonPropertyName("type")] string Type);
|
||||||
|
|
||||||
private sealed record ClaimTaskRequest(
|
private sealed record ClaimTaskRequest(
|
||||||
[property: JsonPropertyName("action")] string Action,
|
[property: JsonPropertyName("action")] string Action,
|
||||||
[property: JsonPropertyName("assignee")] string Assignee);
|
[property: JsonPropertyName("assignee")] string Assignee);
|
||||||
@@ -193,6 +253,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
|
|
||||||
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
||||||
|
|
||||||
|
private sealed record AcquiredEscalatieJob(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId);
|
||||||
|
|
||||||
private sealed record AcquiredJob(
|
private sealed record AcquiredJob(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
||||||
|
|||||||
@@ -16,3 +16,23 @@ public interface IExternalWorkerClient
|
|||||||
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
||||||
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The escalation side of the Workflow Client (S-14): the <c>BeoordelingEscaleren</c> external-worker
|
||||||
|
/// jobs parked by the 14-day boundary timer on <c>Beoordelen</c>, and the reassignment they drive.
|
||||||
|
/// Kept separate from <see cref="IExternalWorkerClient"/> (interface segregation) so the OpenZaak
|
||||||
|
/// worker never sees escalation. Implemented by <see cref="FlowableWorkflowClient"/> — the only code
|
||||||
|
/// that talks to Flowable (§8.2, ADR-0015).
|
||||||
|
/// </summary>
|
||||||
|
public interface IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>BeoordelingEscaleren</c> jobs.</summary>
|
||||||
|
Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Reassign the still-open <c>Beoordelen</c> task in the given process instance from the
|
||||||
|
/// behandelaar group to teamlead. Best-effort no-op if the task is no longer open.</summary>
|
||||||
|
Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
|
||||||
|
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Big.Tests;
|
||||||
|
|
||||||
|
// S-14 (#15): the escalation drain loop. Mirrors OpenZaakJobProcessor — acquire the parked
|
||||||
|
// BeoordelingEscaleren jobs, reassign each instance's Beoordelen task to teamlead, then complete the
|
||||||
|
// job. A job whose reassignment fails is logged and left un-completed for Flowable to redeliver (§8.6).
|
||||||
|
public class BeoordelingEscalatieProcessorTests
|
||||||
|
{
|
||||||
|
/// <summary>A fake escalation client: scripts the jobs to acquire, records reassignments and
|
||||||
|
/// completions, and can be told to throw on reassigning a given instance.</summary>
|
||||||
|
private sealed class FakeEscalatieClient(params EscalatieJob[] jobs) : IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
public int AcquireCount { get; private set; }
|
||||||
|
public List<string> Reassigned { get; } = [];
|
||||||
|
public List<string> Completed { get; } = [];
|
||||||
|
public string? ThrowOnInstance { get; set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
AcquireCount++;
|
||||||
|
return Task.FromResult<IReadOnlyList<EscalatieJob>>(jobs.Take(maxJobs).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (processInstanceId == ThrowOnInstance)
|
||||||
|
throw new InvalidOperationException("reassign failed");
|
||||||
|
Reassigned.Add(processInstanceId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Completed.Add(jobId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquires_an_escalation_reassigns_to_teamlead_and_completes_the_job()
|
||||||
|
{
|
||||||
|
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1"));
|
||||||
|
|
||||||
|
var acquired = await new BeoordelingEscalatieProcessor(
|
||||||
|
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(1, acquired);
|
||||||
|
Assert.Equal("pi-1", Assert.Single(client.Reassigned));
|
||||||
|
Assert.Equal("job-9", Assert.Single(client.Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_failing_reassign_is_left_uncompleted_for_flowable_to_redeliver()
|
||||||
|
{
|
||||||
|
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1")) { ThrowOnInstance = "pi-1" };
|
||||||
|
var logger = new CapturingLogger<BeoordelingEscalatieProcessor>();
|
||||||
|
|
||||||
|
var acquired = await new BeoordelingEscalatieProcessor(client, logger).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(1, acquired);
|
||||||
|
Assert.Empty(client.Completed);
|
||||||
|
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
|
||||||
|
Assert.Contains("job-9", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Does_nothing_but_poll_when_there_are_no_escalations()
|
||||||
|
{
|
||||||
|
var client = new FakeEscalatieClient();
|
||||||
|
|
||||||
|
var acquired = await new BeoordelingEscalatieProcessor(
|
||||||
|
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(0, acquired);
|
||||||
|
Assert.Equal(1, client.AcquireCount);
|
||||||
|
Assert.Empty(client.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -306,4 +306,108 @@ public class FlowableWorkflowClientTests
|
|||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ───────────────
|
||||||
|
// The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as
|
||||||
|
// an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open
|
||||||
|
// Beoordelen task from the behandelaar group to teamlead, then completes the escalation job.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||||
|
"""[{"id":"job-9","processInstanceId":"pi-1"}]"""));
|
||||||
|
|
||||||
|
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3);
|
||||||
|
|
||||||
|
var job = Assert.Single(jobs);
|
||||||
|
Assert.Equal("job-9", job.JobId);
|
||||||
|
Assert.Equal("pi-1", job.ProcessInstanceId);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
|
||||||
|
capture.Seen!.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body);
|
||||||
|
Assert.Contains("\"numberOfTasks\":3", capture.Body);
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("[]")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body)
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||||
|
|
||||||
|
Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1));
|
||||||
|
Assert.NotNull(capture.Seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead()
|
||||||
|
{
|
||||||
|
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
|
||||||
|
var client = Client(new StubHandler(async req =>
|
||||||
|
{
|
||||||
|
requests.Add((req.Method, req.RequestUri!.ToString(),
|
||||||
|
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
|
||||||
|
// The task query returns the still-open Beoordelen task parked in this instance.
|
||||||
|
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""",
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
}
|
||||||
|
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
|
||||||
|
|
||||||
|
// 1. Find the still-open Beoordelen task in this process instance.
|
||||||
|
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
|
||||||
|
Assert.Equal(HttpMethod.Post, query.Method);
|
||||||
|
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
|
||||||
|
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body);
|
||||||
|
// 2. Add teamlead as a candidate group on that task.
|
||||||
|
var add = requests.Single(r => r.Method == HttpMethod.Post
|
||||||
|
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks"));
|
||||||
|
Assert.Contains("\"group\":\"teamlead\"", add.Body);
|
||||||
|
Assert.Contains("\"type\":\"candidate\"", add.Body);
|
||||||
|
// 3. Remove behandelaar as a candidate group — the task now belongs to teamlead.
|
||||||
|
Assert.Contains(requests, r => r.Method == HttpMethod.Delete
|
||||||
|
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open()
|
||||||
|
{
|
||||||
|
// The behandelaar completed it just before the timer fired: nothing to reassign, no throw.
|
||||||
|
var methods = new List<HttpMethod>();
|
||||||
|
var client = Client(new StubHandler(req =>
|
||||||
|
{
|
||||||
|
methods.Add(req.Method);
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
|
||||||
|
|
||||||
|
Assert.DoesNotContain(HttpMethod.Delete, methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
await client.CompleteBeoordelingEscalatieJobAsync("job-9");
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
|
||||||
|
capture.Seen.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
||||||
"reporters": ["progress", "html"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/OpenZaakJobPump.cs"
|
"!**/OpenZaakJobPump.cs",
|
||||||
|
"!**/BeoordelingEscalatiePump.cs"
|
||||||
],
|
],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
||||||
|
<!-- The beoordeling-escalation scenario drives the escalation worker (Infrastructure), as the
|
||||||
|
ACL scenario drives Acl.Infrastructure — escalation has no domain-aggregate surface (S-14). -->
|
||||||
|
<ProjectReference Include="..\..\services\domain\Big.Infrastructure\Big.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
22
tests/acceptance/Features/EenBeoordelingEscaleren.feature
Normal file
22
tests/acceptance/Features/EenBeoordelingEscaleren.feature
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
|
||||||
|
# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
|
||||||
|
# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
|
||||||
|
# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
|
||||||
|
# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
|
||||||
|
Feature: Een beoordeling escaleren
|
||||||
|
Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
|
||||||
|
zodat aanvragen niet blijven liggen.
|
||||||
|
|
||||||
|
Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
|
||||||
|
Given a registration parked at the Beoordelen task for the behandelaar
|
||||||
|
When the 14-day escalation timer fires
|
||||||
|
And the escalation worker runs
|
||||||
|
Then the beoordeling is reassigned to the teamlead
|
||||||
|
|
||||||
|
Scenario: Een tijdig afgeronde beoordeling escaleert niet
|
||||||
|
Given a registration parked at the Beoordelen task for the behandelaar
|
||||||
|
When the behandelaar completes the beoordeling before the timer fires
|
||||||
|
And the 14-day escalation timer fires
|
||||||
|
And the escalation worker runs
|
||||||
|
Then the beoordeling stays with the behandelaar
|
||||||
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
|
||||||
|
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
|
||||||
|
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
|
||||||
|
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
|
||||||
|
Feature: Een registratie intrekken
|
||||||
|
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
|
||||||
|
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
|
||||||
|
|
||||||
|
Scenario: De zorgprofessional trekt zijn eigen registratie in
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When the zorgprofessional withdraws it
|
||||||
|
Then the withdrawal succeeds
|
||||||
|
And the registration has status "INGETROKKEN"
|
||||||
|
And the running process is cancelled
|
||||||
|
|
||||||
|
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When a different zorgprofessional tries to withdraw it
|
||||||
|
Then the withdrawal is reported not found
|
||||||
|
And the registration has status "INGEDIEND"
|
||||||
|
And the running process is not cancelled
|
||||||
44
tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs
Normal file
44
tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenBeoordelingEscaleren.feature</c> (S-14). Drives the escalation worker
|
||||||
|
/// (<see cref="BeoordelingEscalatieProcessor"/>) against an in-memory Flowable stand-in; one instance
|
||||||
|
/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
|
||||||
|
/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een beoordeling escaleren")]
|
||||||
|
public sealed class EenBeoordelingEscalerenSteps
|
||||||
|
{
|
||||||
|
private readonly InMemoryEscalatieClient _flowable = new();
|
||||||
|
private string _processInstanceId = "";
|
||||||
|
|
||||||
|
[Given("a registration parked at the Beoordelen task for the behandelaar")]
|
||||||
|
public void GivenARegistrationParkedForTheBehandelaar()
|
||||||
|
=> _processInstanceId = _flowable.ParkBeoordeling();
|
||||||
|
|
||||||
|
[When("the 14-day escalation timer fires")]
|
||||||
|
public void WhenTheEscalationTimerFires()
|
||||||
|
=> _flowable.FireEscalationTimer(_processInstanceId);
|
||||||
|
|
||||||
|
[When("the behandelaar completes the beoordeling before the timer fires")]
|
||||||
|
public void WhenTheBehandelaarCompletesBeforeTheTimer()
|
||||||
|
=> _flowable.CompleteBeoordeling(_processInstanceId);
|
||||||
|
|
||||||
|
[When("the escalation worker runs")]
|
||||||
|
public async Task WhenTheEscalationWorkerRuns()
|
||||||
|
=> await new BeoordelingEscalatieProcessor(
|
||||||
|
_flowable, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
[Then("the beoordeling is reassigned to the teamlead")]
|
||||||
|
public void ThenTheBeoordelingIsReassignedToTheTeamlead()
|
||||||
|
=> Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
|
||||||
|
|
||||||
|
[Then("the beoordeling stays with the behandelaar")]
|
||||||
|
public void ThenTheBeoordelingStaysWithTheBehandelaar()
|
||||||
|
=> Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
|
||||||
|
}
|
||||||
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (S-11). Submits a registration (which
|
||||||
|
/// records its process) and then applies the WithdrawRegistration use case against in-memory ports;
|
||||||
|
/// one instance per scenario. Scoped to this feature so its "the registration has status" step does
|
||||||
|
/// not clash with the identically phrased steps in the other features.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een registratie intrekken")]
|
||||||
|
public sealed class EenRegistratieIntrekkenSteps
|
||||||
|
{
|
||||||
|
private const string OwnerBsn = "123456782";
|
||||||
|
|
||||||
|
private readonly InMemoryRegistrationStore _store = new();
|
||||||
|
private readonly InMemoryWorkflowClient _workflow = new();
|
||||||
|
private RegistrationId _id;
|
||||||
|
private WithdrawOutcome _outcome;
|
||||||
|
|
||||||
|
[Given("a submitted registration with a running process")]
|
||||||
|
public async Task GivenASubmittedRegistrationWithARunningProcess()
|
||||||
|
=> _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
|
||||||
|
|
||||||
|
[When("the zorgprofessional withdraws it")]
|
||||||
|
public async Task WhenTheZorgprofessionalWithdrawsIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, OwnerBsn));
|
||||||
|
|
||||||
|
[When("a different zorgprofessional tries to withdraw it")]
|
||||||
|
public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, "999999990"));
|
||||||
|
|
||||||
|
[Then("the withdrawal succeeds")]
|
||||||
|
public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
|
||||||
|
|
||||||
|
[Then("the withdrawal is reported not found")]
|
||||||
|
public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
|
||||||
|
|
||||||
|
[Then("the registration has status \"(.*)\"")]
|
||||||
|
public async Task ThenTheRegistrationHasStatus(string expected)
|
||||||
|
{
|
||||||
|
var registration = await _store.GetAsync(_id);
|
||||||
|
Assert.NotNull(registration);
|
||||||
|
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("the running process is cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsCancelled()
|
||||||
|
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
|
||||||
|
|
||||||
|
[Then("the running process is not cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsNotCancelled()
|
||||||
|
=> Assert.Null(_workflow.WithdrawnProcessInstanceId);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Big.Application;
|
using Big.Application;
|
||||||
using Big.Domain;
|
using Big.Domain;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
|
||||||
namespace Acceptance.Support;
|
namespace Acceptance.Support;
|
||||||
|
|
||||||
@@ -73,6 +74,59 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
|
||||||
|
/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
|
||||||
|
/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
|
||||||
|
/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.</summary>
|
||||||
|
public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
private sealed class ParkedTask
|
||||||
|
{
|
||||||
|
public string CandidateGroup { get; set; } = "behandelaar";
|
||||||
|
public bool IsOpen { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ParkedTask> _tasks = [];
|
||||||
|
private readonly List<EscalatieJob> _parked = [];
|
||||||
|
private int _seq;
|
||||||
|
|
||||||
|
/// <summary>A registration parks at Beoordelen, claimable by the behandelaar group.</summary>
|
||||||
|
public string ParkBeoordeling()
|
||||||
|
{
|
||||||
|
var pid = $"pi-{++_seq}";
|
||||||
|
_tasks[pid] = new ParkedTask();
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The behandelaar completes the beoordeling before the timer fires: the task closes.</summary>
|
||||||
|
public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
|
||||||
|
|
||||||
|
/// <summary>The 14-day boundary timer fires: a non-interrupting token parks an escalation job.</summary>
|
||||||
|
public void FireEscalationTimer(string processInstanceId)
|
||||||
|
=> _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
|
||||||
|
|
||||||
|
/// <summary>The candidate group that may currently pick the task up.</summary>
|
||||||
|
public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<EscalatieJob>>(_parked.Take(maxJobs).ToList());
|
||||||
|
|
||||||
|
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// No-op if the behandelaar already completed it — the timer/completion race (§8.6).
|
||||||
|
var task = _tasks[processInstanceId];
|
||||||
|
if (task.IsOpen)
|
||||||
|
task.CandidateGroup = "teamlead";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_parked.RemoveAll(j => j.JobId == jobId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||||
{
|
{
|
||||||
|
|||||||
27
tests/e2e/withdrawal.spec.ts
Normal file
27
tests/e2e/withdrawal.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
||||||
|
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
||||||
|
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
|
||||||
|
// then confirms the registration is ingetrokken.
|
||||||
|
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
|
||||||
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.locator('#username').fill('jan-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /indienen/i }).click();
|
||||||
|
|
||||||
|
// The BFF accepted it and the page shows the confirmation with the reference.
|
||||||
|
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
|
||||||
|
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
|
||||||
|
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -15,7 +15,12 @@
|
|||||||
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
|
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
|
||||||
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
|
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
|
||||||
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
|
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
|
||||||
to the task's execution; the BPMN owns the cancellation path. -->
|
to the task's execution; the BPMN owns the cancellation path.
|
||||||
|
S-14 adds escalation: a NON-interrupting boundary timer (P14D) on Beoordelen. If a behandelaar
|
||||||
|
has not picked the task up within 14 days it fires a parallel token to an external-worker task
|
||||||
|
(BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the
|
||||||
|
behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
|
||||||
|
only changes who may claim it. -->
|
||||||
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
||||||
|
|
||||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||||
@@ -45,6 +50,25 @@
|
|||||||
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
|
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
|
||||||
|
|
||||||
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
|
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
|
||||||
|
|
||||||
|
<!-- Escalation (S-14): non-interrupting P14D boundary timer on Beoordelen. On timeout a parallel
|
||||||
|
token runs EscaleerBeoordeling, an external-worker task the Workflow Client picks up to
|
||||||
|
reassign the still-open Beoordelen task from behandelaar to teamlead (ADR-0015). -->
|
||||||
|
<boundaryEvent id="EscaleerTimer" attachedToRef="Beoordelen" cancelActivity="false">
|
||||||
|
<timerEventDefinition>
|
||||||
|
<timeDuration>P14D</timeDuration>
|
||||||
|
</timerEventDefinition>
|
||||||
|
</boundaryEvent>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow5" sourceRef="EscaleerTimer" targetRef="EscaleerBeoordeling"/>
|
||||||
|
|
||||||
|
<serviceTask id="EscaleerBeoordeling" name="Beoordeling escaleren"
|
||||||
|
flowable:type="external-worker"
|
||||||
|
flowable:topic="BeoordelingEscaleren"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow6" sourceRef="EscaleerBeoordeling" targetRef="endEscaleren"/>
|
||||||
|
|
||||||
|
<endEvent id="endEscaleren" name="Beoordeling geëscaleerd"/>
|
||||||
</process>
|
</process>
|
||||||
|
|
||||||
<bpmndi:BPMNDiagram id="diagram">
|
<bpmndi:BPMNDiagram id="diagram">
|
||||||
@@ -83,6 +107,24 @@
|
|||||||
<omgdi:waypoint x="450" y="165"/>
|
<omgdi:waypoint x="450" y="165"/>
|
||||||
<omgdi:waypoint x="450" y="220"/>
|
<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">
|
||||||
|
<omgdi:waypoint x="495" y="70"/>
|
||||||
|
<omgdi:waypoint x="495" y="50"/>
|
||||||
|
<omgdi:waypoint x="580" y="50"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
|
||||||
|
<omgdi:waypoint x="700" y="50"/>
|
||||||
|
<omgdi:waypoint x="760" y="50"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
</bpmndi:BPMNPlane>
|
</bpmndi:BPMNPlane>
|
||||||
</bpmndi:BPMNDiagram>
|
</bpmndi:BPMNDiagram>
|
||||||
</definitions>
|
</definitions>
|
||||||
|
|||||||
Reference in New Issue
Block a user